UNPKG

dom-track

Version:

Fluent utility to track DOM elements as they appear, change, or get removed — using both callback and Promise-based APIs.

458 lines (453 loc) 12.9 kB
// src/core/track-registry.ts var BaseTracker = class { constructor(container) { this.entries = /* @__PURE__ */ new Map(); this.container = container; } add(selector, callback) { if (!this.entries.has(selector)) { this.entries.set(selector, /* @__PURE__ */ new Set()); } this.entries.get(selector).add(callback); } remove(selector, callback) { const set = this.entries.get(selector); if (!set) return; set.delete(callback); if (set.size === 0) { this.entries.delete(selector); } } isEmpty() { return this.entries.size === 0; } clear() { this.entries.clear(); } closestBounded(element, selector) { let current = element; const container = this.container; do { if (current.matches(selector)) { return current; } if (current === container) { break; } current = current.parentElement; } while (current); return null; } }; var SeenTracker = class extends BaseTracker { // Uses el.matches() - checks if newly created element itself matches selector trigger(el) { for (const [selector, callbacks] of this.entries.entries()) { if (el.matches(selector)) { for (const callback of callbacks) { callback(el, this.container); } } } } }; var ChangedTracker = class extends BaseTracker { // Uses bounded closest() - checks if changed element is inside selector within container boundary trigger(el) { for (const [selector, callbacks] of this.entries.entries()) { if (this.closestBounded(el, selector) !== null) { for (const callback of callbacks) { callback(el, this.container); } } } } }; var RemovalTracker = class { constructor(container) { this.entries = /* @__PURE__ */ new Map(); this.container = container; } addForElement(element, callback) { if (!this.entries.has(element)) { this.entries.set(element, /* @__PURE__ */ new Set()); } this.entries.get(element).add(callback); } remove(callback) { for (const [element, callbacks] of this.entries.entries()) { callbacks.delete(callback); if (callbacks.size === 0) { this.entries.delete(element); } } } trigger(element) { const callbacks = this.entries.get(element); if (callbacks) { for (const callback of callbacks) { callback(element, this.container); } this.entries.delete(element); } } isEmpty() { return this.entries.size === 0; } clear() { this.entries.clear(); } }; var TrackRegistry = class { constructor(container) { this.seen = new SeenTracker(container); this.changed = new ChangedTracker(container); this.removals = new RemovalTracker(container); } isEmpty() { return this.seen.isEmpty() && this.changed.isEmpty() && this.removals.isEmpty(); } clear() { this.seen.clear(); this.changed.clear(); this.removals.clear(); } }; // src/utils/timer.ts function setupTimeouts(timeoutMs, onTimeout, selector = "") { let warnTimer1; let warnTimer2; let errorTimer; if (timeoutMs) { if (timeoutMs > 3e3) { warnTimer1 = setTimeout(() => { console.warn(`\u23F3 Still waiting for ${selector}`); }, 2e3); } if (timeoutMs > 5e3) { warnTimer2 = setTimeout(() => { console.warn(`\u23F3 (2nd) Still waiting for ${selector}`); }, timeoutMs / 2); } errorTimer = setTimeout(() => { console.error(`\u274C Timeout waiting for ${selector}`); onTimeout(); }, timeoutMs); } const clear = () => { if (warnTimer1) clearTimeout(warnTimer1); if (warnTimer2) clearTimeout(warnTimer2); if (errorTimer) clearTimeout(errorTimer); }; return { warnTimer1, warnTimer2, errorTimer, clear }; } function debounce(fn, delay) { let timeout = null; return (...args) => { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => fn(...args), delay); }; } // src/utils/watch-promise.ts var _a; _a = Symbol.toStringTag; var WatchPromise = class { constructor(watchSetup) { this.watchSetup = watchSetup; this.isOnce = true; this.isSetupComplete = false; this.setupTimeoutAndAbort = () => { }; // Required for correct Promise subclassing behavior this[_a] = "Promise"; this.promise = this.createPromise(); } once() { this.isOnce = true; return this; } timeout(ms, onTimeout) { this.timeoutMs = ms; this.timeoutCallback = onTimeout; return this; } abortSignal(signal) { this.signal = signal; return this; } createPromise() { let resolved = false; let timeouts; return new Promise((resolve, reject) => { const cleanup = () => { if (this.watchHandle) this.watchHandle.cancel(); timeouts?.clear(); }; this.watchHandle = this.watchSetup((el) => { if (resolved) return; resolved = true; cleanup(); resolve(el); }, reject); this.setupTimeoutAndAbort = () => { if (this.isSetupComplete) return; this.isSetupComplete = true; if (this.signal?.aborted) { cleanup(); reject(new Error("Aborted")); return; } if (this.signal) { this.signal.addEventListener("abort", () => { cleanup(); reject(new Error("Aborted")); }, { once: true }); } if (this.timeoutMs !== void 0) { timeouts = setupTimeouts(this.timeoutMs, () => { if (!resolved) { if (this.timeoutCallback) this.timeoutCallback(); cleanup(); reject(new Error("Timeout")); } }); } }; }); } ensureSetup() { this.setupTimeoutAndAbort(); } then(onfulfilled, onrejected) { this.ensureSetup(); return this.promise.then(onfulfilled, onrejected); } catch(onrejected) { this.ensureSetup(); return this.promise.catch(onrejected); } finally(onfinally) { this.ensureSetup(); return this.promise.finally(onfinally); } }; // src/utils/utils.ts function deduplicateRoots(roots) { const unique = []; for (const root of roots) { if (![...roots].some((other) => other !== root && other.contains(root))) { unique.push(root); } } return unique; } function collectRootElements(mutations) { const roots = /* @__PURE__ */ new Set(); for (const mutation of mutations) { if (mutation.type === "childList") { for (const node of Array.from(mutation.addedNodes)) { if (node instanceof HTMLElement) { roots.add(node); } } } else if (mutation.type === "attributes" && mutation.target instanceof HTMLElement) { roots.add(mutation.target); } else if (mutation.type === "characterData" && mutation.target.parentElement) { roots.add(mutation.target.parentElement); } } return roots; } function walkElementSubtree(root, callback) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); let current = walker.currentNode; while (current) { callback(current); current = walker.nextNode(); } } // src/dom-track.ts var DomTrack = class { constructor(container, options = {}) { this.bufferedAdditions = []; this.handleMutations = (mutations) => { for (const mutation of mutations) { if (mutation.type === "childList") { if (mutation.removedNodes.length > 0) { this.processRemovals(mutation.removedNodes); } if (mutation.addedNodes.length > 0) { this.bufferedAdditions.push(mutation); } } else if (this.options.observeAttributes && mutation.type === "attributes") { this.bufferedAdditions.push(mutation); } else if (this.options.observeCharacterData && mutation.type === "characterData") { this.bufferedAdditions.push(mutation); } } this.handleBufferedAdditions(); }; this.container = container; this.trackRegistry = new TrackRegistry(container); this.options = { observeChildList: options.observeChildList ?? true, observeAttributes: options.observeAttributes ?? false, observeCharacterData: options.observeCharacterData ?? false, observeSubtree: options.observeSubtree ?? true, debounceMs: options.debounceMs !== void 0 ? options.debounceMs : null, waitForTimeoutMs: options.waitForTimeoutMs ?? 5e3, signal: options.signal }; if (this.options.debounceMs === null) { this.debounceAdditions = this.processAdditions.bind(this); } else { this.debounceAdditions = debounce(this.processAdditions.bind(this), this.options.debounceMs); } if (this.options.signal?.aborted) { this.disconnect(); } else if (this.options.signal) { this.options.signal.addEventListener("abort", () => this.disconnect(), { once: true }); } } attachObserver() { this.observer = new MutationObserver(this.handleMutations); this.observer.observe(this.container, { childList: this.options.observeChildList, attributes: this.options.observeAttributes, characterData: this.options.observeCharacterData, subtree: this.options.observeSubtree }); } ensureObserver() { if (!this.observer) { this.attachObserver(); } } processRemovals(nodes) { const iterableNodes = Array.isArray(nodes) ? nodes : Array.from(nodes); for (const node of iterableNodes) { if (node instanceof HTMLElement) { walkElementSubtree(node, (el) => { this.triggerRemoveCallbacks(el); }); } } } processAdditions() { const rootNodes = collectRootElements(this.bufferedAdditions); const uniqueRoots = deduplicateRoots(rootNodes); const uniqueElements = /* @__PURE__ */ new Set(); for (const root of uniqueRoots) { walkElementSubtree(root, (el) => uniqueElements.add(el)); } for (const el of uniqueElements) { this.trackRegistry.seen.trigger(el); this.trackRegistry.changed.trigger(el); } this.bufferedAdditions = []; this.checkForCleanup(); } handleBufferedAdditions() { if (this.bufferedAdditions.length === 0) return; this.debounceAdditions(); } triggerRemoveCallbacks(el) { this.trackRegistry.removals.trigger(el); this.checkForCleanup(); } checkForCleanup() { if (this.trackRegistry.isEmpty() && this.observer) { this.observer.disconnect(); this.observer = void 0; } } seen(selector, cb) { this.ensureObserver(); if (cb) { this.trackRegistry.seen.add(selector, cb); return { cancel: () => { this.trackRegistry.seen.remove(selector, cb); this.checkForCleanup(); } }; } return new WatchPromise((resolve) => { const cb2 = (el) => resolve(el); return this.seen(selector, cb2); }); } changed(selector, cb) { this.ensureObserver(); if (cb) { this.trackRegistry.changed.add(selector, cb); return { cancel: () => { this.trackRegistry.changed.remove(selector, cb); this.checkForCleanup(); } }; } return new WatchPromise((resolve) => { const cb2 = (el) => resolve(el); return this.changed(selector, cb2); }); } gone(selector, cb) { this.ensureObserver(); const match = (el) => { if (el.matches(selector)) { this.trackRegistry.removals.addForElement(el, cb); } }; walkElementSubtree(this.container, match); return { cancel: () => { this.trackRegistry.removals.remove(cb); this.checkForCleanup(); } }; } goneForElement(el, cb) { this.ensureObserver(); this.trackRegistry.removals.addForElement(el, cb); } disconnect() { if (this.observer) { this.observer.disconnect(); this.observer = void 0; } this.bufferedAdditions = []; this.trackRegistry.clear(); } }; var DomTrackRemovals = class extends DomTrack { constructor(container, options = {}) { super(container, { ...options, observeAttributes: false, // Attributes irrelevant for removals debounceMs: 0 // No debounce, process immediately }); this.handleMutations = (mutations) => { for (const mutation of mutations) { if (mutation.type === "childList") { if (mutation.removedNodes.length > 0) { this.processRemovals(mutation.removedNodes); } } } }; } seen(_1, _2) { throw new Error("DomRemovalObserver does not support watch() or changed(). It only tracks removals."); } changed(_1, _2) { throw new Error("DomRemovalObserver does not support watch() or changed(). It only tracks removals."); } }; export { DomTrack, DomTrackRemovals };