dom-track
Version:
Minimal utility for observing DOM elements appearing and disappearing using callbacks or Promises.
410 lines (401 loc) • 11.3 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
DomTrack: () => DomTrack,
DomTrackRemovals: () => DomTrackRemovals
});
module.exports = __toCommonJS(index_exports);
// src/watcher/base-watcher.ts
var BaseWatcher = class {
constructor() {
this.map = /* @__PURE__ */ new Map();
}
add(key, callback) {
if (!this.map.has(key)) {
this.map.set(key, /* @__PURE__ */ new Set());
}
this.map.get(key).add(callback);
}
remove(key, callback) {
const set = this.map.get(key);
if (!set) return;
set.delete(callback);
if (set.size === 0) {
this.map.delete(key);
}
}
isEmpty() {
return this.map.size === 0;
}
};
// src/watcher/creation-watcher.ts
var CreationWatcher = class extends BaseWatcher {
trigger(el) {
for (const [selector, callbacks] of this.map.entries()) {
if (el.matches(selector)) {
for (const cb of callbacks) {
cb(el);
}
}
}
}
};
// src/watcher/mutation-watcher.ts
var MutationWatcher = class extends BaseWatcher {
trigger(el) {
for (const [selector, callbacks] of this.map.entries()) {
if (el.closest(selector)) {
for (const cb of callbacks) {
cb(el);
}
}
}
}
};
// 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;
// Required for correct Promise subclassing behavior
this[_a] = "Promise";
}
once() {
this.isOnce = true;
return this;
}
timeout(ms, onTimeout) {
this.timeoutMs = ms;
this.timeoutCallback = onTimeout;
return this;
}
abortSignal(signal) {
this.signal = signal;
return this;
}
toPromise() {
let watchHandle;
let resolved = false;
let timeouts;
return new Promise((resolve, reject) => {
const cleanup = () => {
if (this.watchHandle) this.watchHandle.cancel();
timeouts?.clear();
};
if (this.signal?.aborted) {
cleanup();
return reject(new Error("Aborted"));
}
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"));
}
});
}
this.watchHandle = this.watchSetup((el) => {
if (resolved) return;
resolved = true;
cleanup();
resolve(el);
}, reject);
});
}
then(onfulfilled, onrejected) {
return this.toPromise().then(onfulfilled, onrejected);
}
catch(onrejected) {
return this.toPromise().catch(onrejected);
}
finally(onfinally) {
return this.toPromise().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) {
for (const node of mutation.addedNodes) {
if (node instanceof HTMLElement) {
roots.add(node);
}
}
}
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.watchSeen = new CreationWatcher();
this.watchChanged = new MutationWatcher();
this.removals = /* @__PURE__ */ new Map();
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);
}
}
this.handleBufferedAdditions();
};
this.container = container;
this.options = {
observeAttributes: options.observeAttributes ?? false,
observeSubtree: options.observeSubtree ?? true,
debounceMs: options.debounceMs ?? 0,
waitForTimeoutMs: options.waitForTimeoutMs ?? 5e3,
signal: options.signal
};
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: true,
subtree: this.options.observeSubtree,
attributes: this.options.observeAttributes
});
}
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.watchSeen.trigger(el);
this.watchChanged.trigger(el);
}
this.bufferedAdditions = [];
this.checkForCleanup();
}
handleBufferedAdditions() {
if (this.bufferedAdditions.length === 0) return;
this.debounceAdditions();
}
triggerRemoveCallbacks(el) {
if (!this.removals.has(el)) return;
const callbacks = this.removals.get(el);
if (callbacks) {
for (const cb of callbacks) {
cb(el);
}
}
this.removals.delete(el);
this.checkForCleanup();
}
checkForCleanup() {
if (this.watchSeen.isEmpty() && this.watchChanged.isEmpty() && this.removals.size === 0 && this.observer) {
this.observer.disconnect();
this.observer = void 0;
}
}
seen(selector, cb) {
this.ensureObserver();
if (cb) {
this.watchSeen.add(selector, cb);
return {
cancel: () => {
this.watchSeen.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.watchChanged.add(selector, cb);
return {
cancel: () => {
this.watchChanged.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.goneCallback(el, cb);
}
};
walkElementSubtree(this.container, match);
return {
cancel: () => {
for (const [el, cbs] of this.removals.entries()) {
cbs.delete(cb);
if (cbs.size === 0) {
this.removals.delete(el);
}
}
this.checkForCleanup();
}
};
}
goneForElement(el, cb) {
this.ensureObserver();
this.goneCallback(el, cb);
}
goneCallback(el, cb) {
if (!this.removals.has(el)) {
this.removals.set(el, /* @__PURE__ */ new Set());
}
this.removals.get(el).add(cb);
}
disconnect() {
if (this.observer) {
this.observer.disconnect();
this.observer = void 0;
}
this.bufferedAdditions = [];
this.watchSeen = new CreationWatcher();
this.watchChanged = new MutationWatcher();
this.removals.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(). It only tracks removals.");
}
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
DomTrack,
DomTrackRemovals
});