symbiotic
Version:
A lightweight DOM attachment framework
579 lines (502 loc) • 18.4 kB
JavaScript
/*!
* Symbiote - A lightweight DOM attachment framework
* @version 1.0.0
* @license MIT
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Symbiote = {}));
})(this, (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 });
}));