UNPKG

symbiotic

Version:

A lightweight DOM attachment framework

578 lines (500 loc) 18.1 kB
/*! * Symbiote - A lightweight DOM attachment framework * @version 1.0.0 * @license MIT */ var Symbiote = (function (exports) { 'use strict'; function documentLoaded() { return new Promise(resolve => { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', resolve); } else { resolve(); } }); } /** * Selector indexing and matching utilities * * This module handles the efficient indexing and matching of CSS selectors * to optimize DOM element selection and attachment. */ // Lightweight selector index const selectorIndex = { id: new Map(), // id -> Set<selectors starting with #id> class: new Map(), // class -> Set<selectors starting with .class> tag: new Map(), // tagName(lower) or '*' -> Set<selectors> other: new Set(), // Set<selectors> }; /** * Check if a selector is stable (not a pseudo-selector) * Pseudo-selectors are dynamic and unreliable for DOM attachment */ function isStableSelector(selector) { // Exclude all pseudo-selectors as they are dynamic and unreliable for DOM attachment return !/:[a-zA-Z-]/.test(selector); } /** * Index a selector for efficient lookup */ function indexSelector(selector) { const s = selector.trim(); // id bucket if (s.startsWith('#')) { const id = s.slice(1).split(/[^-_a-zA-Z0-9]/, 1)[0]; if (!selectorIndex.id.has(id)) selectorIndex.id.set(id, new Set()); selectorIndex.id.get(id).add(s); return; } // class bucket - handle both simple and complex selectors if (s.includes('.')) { // Extract all class names from the selector const classMatches = s.match(/\.[a-zA-Z0-9_-]+/g); if (classMatches) { for (const classMatch of classMatches) { const cls = classMatch.slice(1); // Remove the dot if (!selectorIndex.class.has(cls)) selectorIndex.class.set(cls, new Set()); selectorIndex.class.get(cls).add(s); } return; } } // tag or * - handle attribute selectors like button[disabled] const first = s.split(/[\s.#[:]/, 1)[0].toLowerCase(); if (first && (/^[a-z][a-z0-9-]*$/.test(first) || first === '*')) { if (!selectorIndex.tag.has(first)) selectorIndex.tag.set(first, new Set()); selectorIndex.tag.get(first).add(s); return; } // attribute selectors like [data-required="true"] - index by tag if present if (s.startsWith('[')) { // Try to extract tag name from attribute selectors like button[disabled] const tagMatch = s.match(/^([a-z][a-z0-9-]*)\[/); if (tagMatch) { const tag = tagMatch[1]; if (!selectorIndex.tag.has(tag)) selectorIndex.tag.set(tag, new Set()); selectorIndex.tag.get(tag).add(s); return; } } selectorIndex.other.add(s); } /** * Remove a selector from the index */ function deindexSelector(selector) { const s = selector.trim(); let removed = false; if (s.startsWith('#')) { const id = s.slice(1).split(/[^-_a-zA-Z0-9]/, 1)[0]; const set = selectorIndex.id.get(id); if (set) { set.delete(s); if (!set.size) selectorIndex.id.delete(id); removed = true; } } else if (s.includes('.')) { // Extract all class names from the selector const classMatches = s.match(/\.[a-zA-Z0-9_-]+/g); if (classMatches) { for (const classMatch of classMatches) { const cls = classMatch.slice(1); // Remove the dot const set = selectorIndex.class.get(cls); if (set) { set.delete(s); if (!set.size) selectorIndex.class.delete(cls); removed = true; } } } } else { const first = s.split(/[\s.#[:]/, 1)[0].toLowerCase(); if (first && (/^[a-z][a-z0-9-]*$/.test(first) || first === '*')) { const set = selectorIndex.tag.get(first); if (set) { set.delete(s); if (!set.size) selectorIndex.tag.delete(first); removed = true; } } } // Handle attribute selectors like [data-required="true"] if (!removed && s.startsWith('[')) { const tagMatch = s.match(/^([a-z][a-z0-9-]*)\[/); if (tagMatch) { const tag = tagMatch[1]; const set = selectorIndex.tag.get(tag); if (set) { set.delete(s); if (!set.size) selectorIndex.tag.delete(tag); removed = true; } } } if (!removed) selectorIndex.other.delete(s); } /** * Generator function that yields candidate selectors for an element * This efficiently finds likely selectors before calling el.matches() */ function* candidateSelectorsFor(el) { // Build a small set of likely selectors before calling el.matches() const out = new Set(); const id = el.id?.trim(); if (id && selectorIndex.id.has(id)) { for (const s of selectorIndex.id.get(id)) out.add(s); } if (el.classList && el.classList.length) { for (const cls of el.classList) { const set = selectorIndex.class.get(cls); if (set) for (const s of set) out.add(s); } } const tag = el.tagName?.toLowerCase(); if (tag) { const tset = selectorIndex.tag.get(tag); if (tset) for (const s of tset) out.add(s); const allset = selectorIndex.tag.get('*'); if (allset) for (const s of allset) out.add(s); } // Always include complex selectors for (const s of selectorIndex.other) out.add(s); yield* out; } /** * Element state management utilities * * This module handles tracking which selectors are attached to elements * and managing cleanup functions for proper resource management. */ // Per-element attachment state (keyed by selector) const attachedBySelector = new WeakMap(); // Element -> Set<selectors> const cleanupBySelector = new WeakMap(); // Element -> Map<selectors, fn> // "Direct" attachments (from template render with raw setup fns, not selector-based) const directCleanupMap = new WeakMap(); // Element -> Set<fn> /** * Get or create the set of attached selectors for an element */ function getAttachedSet(el) { let set = attachedBySelector.get(el); if (!set) { set = new Set(); attachedBySelector.set(el, set); } return set; } /** * Get or create the cleanup map for an element */ function getCleanupMap(el) { let map = cleanupBySelector.get(el); if (!map) { map = new Map(); cleanupBySelector.set(el, map); } return map; } /** * Add a direct cleanup function for an element */ function addDirectCleanup(el, fn) { if (typeof fn !== 'function') return; let set = directCleanupMap.get(el); if (!set) { set = new Set(); directCleanupMap.set(el, set); } set.add(fn); } /** * Run all direct cleanup functions for an element */ function runDirectCleanups(el) { const set = directCleanupMap.get(el); if (!set) return; for (const fn of set) { try { fn(); } catch (e) { console.error(e); } } set.clear(); directCleanupMap.delete(el); } /** * Global registry management * * This module manages the global state for setup functions and symbiote instances * across the entire application. */ // Global registries const setupFunctions = new Map(); // selector -> setupFn const symbioteInstances = new Set(); /** * Register or replace a setup function globally by selector */ function defineSetup(selector, setupFunction) { if (!isStableSelector(selector)) { console.warn(`Ignoring unstable selector: ${selector}`); return { remove() {} }; } // If setupFunction is null, remove the setup function if (setupFunction === null) { if (setupFunctions.has(selector)) { symbioteInstances.forEach(instance => { instance.cleanup(selector); }); setupFunctions.delete(selector); deindexSelector(selector); } return { remove() {} }; } // If replacing, detach existing attachments for that selector across instances if (setupFunctions.has(selector)) { symbioteInstances.forEach(instance => { instance.cleanup(selector); }); deindexSelector(selector); } setupFunctions.set(selector, setupFunction); indexSelector(selector); // Apply to existing elements across instances symbioteInstances.forEach(instance => { instance.checkFor(selector); }); return { remove: () => { symbioteInstances.forEach(instance => instance.cleanup(selector)); setupFunctions.delete(selector); deindexSelector(selector); } }; } /** * Register a symbiote instance in the global registry */ function registerSymbioteInstance(instance) { symbioteInstances.add(instance); } /** * Unregister a symbiote instance from the global registry */ function unregisterSymbioteInstance(instance) { symbioteInstances.delete(instance); } // ---------- Symbiote ---------- class Symbiote { #mutationObserver = null; #changeQueue = new Set(); #flushScheduled = false; #root = null; constructor(functions = {}) { // Register selectors as-is if (functions && typeof functions === 'object') { for (const [selector, setup] of Object.entries(functions)) { if (!isStableSelector(selector)) continue; setupFunctions.set(selector, setup); indexSelector(selector); } } this.#mutationObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList') { mutation.addedNodes.forEach(node => this.#checkNodeOrTree(node)); mutation.removedNodes.forEach(node => this.#detachTree(node)); } else if (mutation.type === 'attributes') { const el = mutation.target; if (el.nodeType === 1) this.#reconcileElementSelectors(el); // HTMLElement } } }); } async attach(root = document.body) { if (root === document.body) { await documentLoaded(); } this.#root = root; // Watch all attributes. Selector matching can depend on any attribute. this.#mutationObserver.observe(root, { childList: true, subtree: true, attributes: true, attributeOldValue: true }); // Initial scan this.#walk(root); } batch(operations) { if (typeof operations === 'function') return this.#addToChangeQueue(operations); return Promise.resolve(); } update() { if (!this.#root) return; this.#walk(this.#root, /*reconcileExisting=*/true); } destroy() { unregisterSymbioteInstance(this); if (this.#mutationObserver) this.#mutationObserver.disconnect(); if (this.#root) this.#detachTree(this.#root); this.#root = null; } // Clean up elements that currently match a selector cleanup(selector) { const root = this.#root || document; root.querySelectorAll(selector).forEach(el => this.#detachSelector(el, selector)); } // Apply a newly added selector's setup to matching elements checkFor(selector) { const root = this.#root || document; root.querySelectorAll(selector).forEach(el => { const attached = getAttachedSet(el); if (!attached.has(selector)) { this.#attachSelector(el, selector, setupFunctions.get(selector)); } }); } // ====== Private ====== #walk(root, reconcileExisting = false) { this.#mutationObserver.disconnect(); const walker = document.createTreeWalker(root, 1, null, false); // NodeFilter.SHOW_ELEMENT = 1 let node = root.nodeType === 1 ? root : null; // HTMLElement if (node) this.#checkNode(node, reconcileExisting); while ((node = walker.nextNode())) this.#checkNode(node, reconcileExisting); if (this.#root) { this.#mutationObserver.observe(this.#root, { childList: true, subtree: true, attributes: true, attributeOldValue: true }); } } #checkNodeOrTree(node) { if (!node || typeof node.nodeType !== 'number') return; if (node.nodeType === 1) this.#checkNode(node, /*reconcile*/true); // HTMLElement if (node.hasChildNodes && node.childNodes && node.childNodes.length) { const walker = document.createTreeWalker(node, 1, null, false); // NodeFilter.SHOW_ELEMENT = 1 let n; while ((n = walker.nextNode())) this.#checkNode(n, /*reconcile*/true); } } #checkNode(el, reconcileExisting) { if (el.nodeType !== 1) return; // HTMLElement if (reconcileExisting) this.#reconcileElementSelectors(el); for (const selector of candidateSelectorsFor(el)) { const setup = setupFunctions.get(selector); if (!setup) continue; const attached = getAttachedSet(el); if (!attached.has(selector) && isStableSelector(selector) && el.matches(selector)) { this.#attachSelector(el, selector, setup); } } } #reconcileElementSelectors(el) { const attached = getAttachedSet(el); if (attached.size) { for (const selector of Array.from(attached)) { // If element no longer matches this selector, detach it if (!el.matches(selector)) { this.#detachSelector(el, selector); } } } // Try to attach any missing selectors now matching for (const selector of candidateSelectorsFor(el)) { const setup = setupFunctions.get(selector); if (setup && !attached.has(selector) && isStableSelector(selector) && el.matches(selector)) { this.#attachSelector(el, selector, setup); } } } #attachSelector(el, selector, setupFunction) { if (!setupFunction) return; const attached = getAttachedSet(el); if (attached.has(selector)) return; const args = el.tagName === 'TEMPLATE' ? [this.#createRenderFunction(el), this.#createBatchFunction()] : [el, this.#createBatchFunction()]; try { const cleanup = setupFunction(...args); if (typeof cleanup === 'function') { const cmap = getCleanupMap(el); cmap.set(selector, cleanup); } attached.add(selector); } catch (error) { console.error(`Error attaching selector "${selector}":`, error); } } #detachSelector(el, selector) { const attached = getAttachedSet(el); if (!attached.has(selector)) return; const cmap = getCleanupMap(el); const cleanup = cmap.get(selector); if (cleanup) { try { cleanup(); } catch (e) { console.error(e); } cmap.delete(selector); } attached.delete(selector); if (attached.size === 0) { // Clean up the WeakMap entry if no selectors are attached attachedBySelector.delete(el); } if (cmap.size === 0) { // Clean up the WeakMap entry if no cleanup functions remain cleanupBySelector.delete(el); } } #detachAllForElement(el) { const cmap = getCleanupMap(el); if (cmap) { for (const [selector] of cmap) this.#detachSelector(el, selector); } runDirectCleanups(el); } #detachTree(node) { if (!node || typeof node.nodeType !== 'number') return; if (node.nodeType === 1) this.#detachAllForElement(node); // HTMLElement if (node.hasChildNodes && node.childNodes && node.childNodes.length) { const walker = node.ownerDocument.createTreeWalker(node, 1, null, false); // NodeFilter.SHOW_ELEMENT = 1 let n; while ((n = walker.nextNode())) this.#detachAllForElement(n); } } #createBatchFunction() { return (operations) => this.batch(operations); } #createRenderFunction(template) { let currentRenderedNodes = []; const detachRenderedTree = () => { for (const n of currentRenderedNodes) { this.#detachTree(n); if (n.parentNode) n.parentNode.removeChild(n); } currentRenderedNodes = []; }; return (childSetupFunction) => { detachRenderedTree(); const clonedContent = document.importNode(template.content, true); currentRenderedNodes = Array.from(clonedContent.children); template.after(clonedContent); if (typeof childSetupFunction === 'function') { for (const node of currentRenderedNodes) { if (node.nodeType !== 1) continue; // HTMLElement try { const cleanup = childSetupFunction(node, this.#createBatchFunction()); if (typeof cleanup === 'function') addDirectCleanup(node, cleanup); } catch (e) { console.error('Error in template child setup:', e); } } } }; } #addToChangeQueue(operations) { return new Promise((resolve, reject) => { this.#changeQueue.add({ operations, resolve, reject }); this.#scheduleFlush(); }); } #scheduleFlush() { if (this.#flushScheduled) return; this.#flushScheduled = true; const scheduleCallback = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : (cb) => setTimeout(cb, 0); scheduleCallback(() => { this.#changeQueue.forEach(change => { try { change.operations(); change.resolve(); } catch (error) { change.reject(error); } }); this.#flushScheduled = false; this.#changeQueue.clear(); }); } } // Public API function createSymbiote(modules) { const symbiote = new Symbiote(modules); registerSymbioteInstance(symbiote); return symbiote; } exports.createSymbiote = createSymbiote; exports.default = createSymbiote; exports.defineSetup = defineSetup; Object.defineProperty(exports, '__esModule', { value: true }); return exports; })({});