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
JavaScript
// 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
};