JavaScript APIs

JavaScript Mutation Observer API: Monitoring DOM Changes

Master the Mutation Observer API to efficiently detect and respond to DOM mutations. Learn to track attribute changes, node additions/removals, and text updates.

By JavaScript Document Team
mutation-observerweb-apisdomreactiveperformance

The Mutation Observer API provides a way to watch for changes being made to the DOM tree. It's designed to replace the older Mutation Events feature and offers better performance and more flexibility for monitoring DOM mutations.

Understanding Mutation Observer

Mutation Observer allows you to observe changes to elements, their attributes, text content, and child nodes without impacting performance like the deprecated mutation events.

Basic Mutation Observer Setup

// Create a MutationObserver instance
const observer = new MutationObserver((mutations, observer) => {
  mutations.forEach((mutation) => {
    console.log('Mutation detected:', mutation);
    console.log('Type:', mutation.type);
    console.log('Target:', mutation.target);

    if (mutation.type === 'childList') {
      console.log('Added nodes:', mutation.addedNodes);
      console.log('Removed nodes:', mutation.removedNodes);
    } else if (mutation.type === 'attributes') {
      console.log('Attribute name:', mutation.attributeName);
      console.log('Old value:', mutation.oldValue);
    } else if (mutation.type === 'characterData') {
      console.log('Old text:', mutation.oldValue);
    }
  });
});

// Configuration options
const config = {
  childList: true, // Observe child node additions/removals
  attributes: true, // Observe attribute changes
  characterData: true, // Observe text content changes
  subtree: true, // Observe all descendants
  attributeOldValue: true, // Record previous attribute values
  characterDataOldValue: true, // Record previous text content
  attributeFilter: ['class', 'id', 'style'], // Only observe specific attributes
};

// Start observing
const targetNode = document.getElementById('target');
observer.observe(targetNode, config);

// Stop observing
observer.disconnect();

// Get pending mutations and clear the queue
const records = observer.takeRecords();
console.log('Pending mutations:', records);

Mutation Types and Records

// Detailed mutation handling
class MutationHandler {
  constructor() {
    this.observer = new MutationObserver(this.handleMutations.bind(this));
  }

  handleMutations(mutations) {
    mutations.forEach((mutation) => {
      switch (mutation.type) {
        case 'childList':
          this.handleChildListMutation(mutation);
          break;
        case 'attributes':
          this.handleAttributeMutation(mutation);
          break;
        case 'characterData':
          this.handleCharacterDataMutation(mutation);
          break;
      }
    });
  }

  handleChildListMutation(mutation) {
    // Handle added nodes
    mutation.addedNodes.forEach((node) => {
      if (node.nodeType === Node.ELEMENT_NODE) {
        console.log('Element added:', node.tagName);
        this.onElementAdded(node);
      } else if (node.nodeType === Node.TEXT_NODE) {
        console.log('Text node added:', node.textContent);
      }
    });

    // Handle removed nodes
    mutation.removedNodes.forEach((node) => {
      if (node.nodeType === Node.ELEMENT_NODE) {
        console.log('Element removed:', node.tagName);
        this.onElementRemoved(node);
      }
    });

    // Check position changes
    if (mutation.previousSibling) {
      console.log('Previous sibling:', mutation.previousSibling);
    }
    if (mutation.nextSibling) {
      console.log('Next sibling:', mutation.nextSibling);
    }
  }

  handleAttributeMutation(mutation) {
    const { target, attributeName, oldValue } = mutation;
    const newValue = target.getAttribute(attributeName);

    console.log(`Attribute '${attributeName}' changed`);
    console.log(`Old value: ${oldValue}`);
    console.log(`New value: ${newValue}`);

    // Handle specific attributes
    switch (attributeName) {
      case 'class':
        this.onClassChange(target, oldValue, newValue);
        break;
      case 'style':
        this.onStyleChange(target, oldValue, newValue);
        break;
      case 'data-state':
        this.onStateChange(target, oldValue, newValue);
        break;
    }
  }

  handleCharacterDataMutation(mutation) {
    const { target, oldValue } = mutation;
    const newValue = target.textContent;

    console.log('Text content changed');
    console.log(`Old: "${oldValue}"`);
    console.log(`New: "${newValue}"`);

    this.onTextChange(target, oldValue, newValue);
  }

  // Custom handlers
  onElementAdded(element) {
    // Initialize new elements
    if (element.classList.contains('lazy-load')) {
      this.initLazyLoading(element);
    }
  }

  onElementRemoved(element) {
    // Cleanup for removed elements
    this.cleanup(element);
  }

  onClassChange(element, oldClasses, newClasses) {
    const oldList = oldClasses ? oldClasses.split(' ') : [];
    const newList = newClasses ? newClasses.split(' ') : [];

    const added = newList.filter((cls) => !oldList.includes(cls));
    const removed = oldList.filter((cls) => !newList.includes(cls));

    console.log('Classes added:', added);
    console.log('Classes removed:', removed);
  }

  onStyleChange(element, oldStyle, newStyle) {
    // Parse style changes
    const oldStyles = this.parseInlineStyle(oldStyle);
    const newStyles = this.parseInlineStyle(newStyle);

    Object.keys(newStyles).forEach((prop) => {
      if (oldStyles[prop] !== newStyles[prop]) {
        console.log(`Style ${prop}: ${oldStyles[prop]} → ${newStyles[prop]}`);
      }
    });
  }

  onStateChange(element, oldState, newState) {
    console.log(`State transition: ${oldState} → ${newState}`);
  }

  onTextChange(node, oldText, newText) {
    // Handle text updates
  }

  parseInlineStyle(styleStr) {
    const styles = {};
    if (!styleStr) return styles;

    styleStr.split(';').forEach((rule) => {
      const [property, value] = rule.split(':').map((s) => s.trim());
      if (property && value) {
        styles[property] = value;
      }
    });

    return styles;
  }

  observe(target, config) {
    this.observer.observe(target, config);
  }

  disconnect() {
    this.observer.disconnect();
  }

  initLazyLoading(element) {
    console.log('Initializing lazy loading for:', element);
  }

  cleanup(element) {
    console.log('Cleaning up:', element);
  }
}

Practical Applications

Dynamic Content Loading Detection

// Auto-initialize components when added to DOM
class ComponentAutoInitializer {
  constructor() {
    this.componentMap = new Map([
      ['data-carousel', this.initCarousel],
      ['data-tooltip', this.initTooltip],
      ['data-modal', this.initModal],
      ['data-tabs', this.initTabs],
    ]);

    this.initialized = new WeakSet();
    this.observer = null;

    this.init();
  }

  init() {
    // Initialize existing components
    this.initializeAll(document.body);

    // Watch for new components
    this.observer = new MutationObserver((mutations) => {
      const addedElements = new Set();

      mutations.forEach((mutation) => {
        if (mutation.type === 'childList') {
          mutation.addedNodes.forEach((node) => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              addedElements.add(node);
              // Check descendants
              const descendants = node.querySelectorAll('*');
              descendants.forEach((desc) => addedElements.add(desc));
            }
          });
        }
      });

      // Initialize new components
      addedElements.forEach((element) => {
        this.initializeElement(element);
      });
    });

    this.observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }

  initializeAll(root) {
    this.componentMap.forEach((initFn, attribute) => {
      const elements = root.querySelectorAll(`[${attribute}]`);
      elements.forEach((element) => {
        this.initializeElement(element);
      });
    });
  }

  initializeElement(element) {
    // Skip if already initialized
    if (this.initialized.has(element)) return;

    // Check each component type
    this.componentMap.forEach((initFn, attribute) => {
      if (element.hasAttribute(attribute)) {
        console.log(`Initializing ${attribute} component:`, element);
        initFn.call(this, element);
        this.initialized.add(element);
      }
    });
  }

  initCarousel(element) {
    const options = JSON.parse(element.dataset.carousel || '{}');
    // Initialize carousel library
    console.log('Carousel initialized with options:', options);
  }

  initTooltip(element) {
    const text = element.dataset.tooltip;
    // Initialize tooltip
    console.log('Tooltip initialized:', text);
  }

  initModal(element) {
    const modalId = element.dataset.modal;
    // Initialize modal
    console.log('Modal initialized:', modalId);
  }

  initTabs(element) {
    const tabsConfig = element.dataset.tabs;
    // Initialize tabs
    console.log('Tabs initialized:', tabsConfig);
  }

  destroy() {
    if (this.observer) {
      this.observer.disconnect();
    }
  }
}

// Form validation observer
class FormValidationObserver {
  constructor(form) {
    this.form = form;
    this.fields = new Map();
    this.observer = null;

    this.init();
  }

  init() {
    // Set up initial field tracking
    this.form.querySelectorAll('input, textarea, select').forEach((field) => {
      this.trackField(field);
    });

    // Watch for form changes
    this.observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === 'childList') {
          // Handle added fields
          mutation.addedNodes.forEach((node) => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              if (this.isFormField(node)) {
                this.trackField(node);
              }
              // Check descendants
              node
                .querySelectorAll('input, textarea, select')
                .forEach((field) => {
                  this.trackField(field);
                });
            }
          });

          // Handle removed fields
          mutation.removedNodes.forEach((node) => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              if (this.isFormField(node)) {
                this.untrackField(node);
              }
              // Check descendants
              node
                .querySelectorAll('input, textarea, select')
                .forEach((field) => {
                  this.untrackField(field);
                });
            }
          });
        } else if (mutation.type === 'attributes') {
          // Handle attribute changes on form fields
          if (this.isFormField(mutation.target)) {
            this.handleFieldAttributeChange(mutation);
          }
        }
      });
    });

    this.observer.observe(this.form, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: [
        'required',
        'pattern',
        'min',
        'max',
        'minlength',
        'maxlength',
        'disabled',
      ],
    });
  }

  isFormField(element) {
    return element.matches('input, textarea, select');
  }

  trackField(field) {
    if (this.fields.has(field)) return;

    const validation = {
      rules: this.extractValidationRules(field),
      isValid: true,
      errors: [],
    };

    this.fields.set(field, validation);

    // Add event listeners
    field.addEventListener('input', () => this.validateField(field));
    field.addEventListener('blur', () => this.validateField(field));

    console.log('Tracking field:', field.name, validation.rules);
  }

  untrackField(field) {
    this.fields.delete(field);
    console.log('Untracking field:', field.name);
  }

  extractValidationRules(field) {
    const rules = {};

    if (field.required) rules.required = true;
    if (field.pattern) rules.pattern = field.pattern;
    if (field.minLength >= 0) rules.minLength = field.minLength;
    if (field.maxLength >= 0) rules.maxLength = field.maxLength;
    if (field.min) rules.min = field.min;
    if (field.max) rules.max = field.max;
    if (field.type === 'email') rules.email = true;
    if (field.type === 'url') rules.url = true;

    return rules;
  }

  handleFieldAttributeChange(mutation) {
    const field = mutation.target;
    const validation = this.fields.get(field);

    if (!validation) return;

    // Update validation rules
    validation.rules = this.extractValidationRules(field);

    console.log(
      `Validation rules updated for ${field.name}:`,
      validation.rules
    );

    // Revalidate with new rules
    this.validateField(field);
  }

  validateField(field) {
    const validation = this.fields.get(field);
    if (!validation) return;

    validation.errors = [];
    validation.isValid = true;

    const value = field.value;
    const rules = validation.rules;

    // Apply validation rules
    if (rules.required && !value) {
      validation.errors.push('This field is required');
    }

    if (value && rules.pattern && !new RegExp(rules.pattern).test(value)) {
      validation.errors.push('Invalid format');
    }

    if (value && rules.minLength && value.length < rules.minLength) {
      validation.errors.push(`Minimum length is ${rules.minLength}`);
    }

    if (value && rules.maxLength && value.length > rules.maxLength) {
      validation.errors.push(`Maximum length is ${rules.maxLength}`);
    }

    validation.isValid = validation.errors.length === 0;

    // Update UI
    this.updateFieldUI(field, validation);
  }

  updateFieldUI(field, validation) {
    field.classList.toggle('is-valid', validation.isValid);
    field.classList.toggle('is-invalid', !validation.isValid);

    // Update error message
    const errorElement = field.parentElement.querySelector('.error-message');
    if (errorElement) {
      errorElement.textContent = validation.errors.join(', ');
    }
  }

  isFormValid() {
    let isValid = true;

    this.fields.forEach((validation, field) => {
      this.validateField(field);
      if (!validation.isValid) {
        isValid = false;
      }
    });

    return isValid;
  }

  destroy() {
    if (this.observer) {
      this.observer.disconnect();
    }
  }
}

Virtual DOM Diffing

// Simple virtual DOM implementation with MutationObserver
class VirtualDOM {
  constructor(container) {
    this.container = container;
    this.vdom = null;
    this.observer = null;
    this.ignoreNextMutation = false;

    this.setupObserver();
  }

  setupObserver() {
    this.observer = new MutationObserver((mutations) => {
      if (this.ignoreNextMutation) {
        this.ignoreNextMutation = false;
        return;
      }

      console.warn('External DOM mutation detected:', mutations);
      // Could reconcile external changes or warn about them
    });

    this.observer.observe(this.container, {
      childList: true,
      attributes: true,
      characterData: true,
      subtree: true,
    });
  }

  createElement(type, props, ...children) {
    return {
      type,
      props: props || {},
      children: children.flat(),
    };
  }

  render(vnode) {
    if (typeof vnode === 'string' || typeof vnode === 'number') {
      return document.createTextNode(vnode);
    }

    const element = document.createElement(vnode.type);

    // Set properties
    Object.entries(vnode.props).forEach(([key, value]) => {
      if (key.startsWith('on')) {
        const event = key.substring(2).toLowerCase();
        element.addEventListener(event, value);
      } else if (key === 'className') {
        element.className = value;
      } else if (key === 'style' && typeof value === 'object') {
        Object.assign(element.style, value);
      } else {
        element.setAttribute(key, value);
      }
    });

    // Render children
    vnode.children.forEach((child) => {
      element.appendChild(this.render(child));
    });

    return element;
  }

  diff(oldVNode, newVNode) {
    // No old node - create new
    if (!oldVNode) {
      return { type: 'CREATE', newVNode };
    }

    // No new node - remove old
    if (!newVNode) {
      return { type: 'REMOVE' };
    }

    // Different types - replace
    if (typeof oldVNode !== typeof newVNode) {
      return { type: 'REPLACE', newVNode };
    }

    // Text nodes
    if (typeof newVNode === 'string' || typeof newVNode === 'number') {
      if (oldVNode !== newVNode) {
        return { type: 'TEXT', newVNode };
      }
      return null;
    }

    // Different element types
    if (oldVNode.type !== newVNode.type) {
      return { type: 'REPLACE', newVNode };
    }

    // Same type - check props and children
    const propPatches = this.diffProps(oldVNode.props, newVNode.props);
    const childPatches = this.diffChildren(
      oldVNode.children,
      newVNode.children
    );

    if (propPatches || childPatches.length > 0) {
      return { type: 'UPDATE', propPatches, childPatches };
    }

    return null;
  }

  diffProps(oldProps, newProps) {
    const patches = {};
    let hasPatches = false;

    // Check for changed/added props
    Object.keys(newProps).forEach((key) => {
      if (oldProps[key] !== newProps[key]) {
        patches[key] = newProps[key];
        hasPatches = true;
      }
    });

    // Check for removed props
    Object.keys(oldProps).forEach((key) => {
      if (!(key in newProps)) {
        patches[key] = undefined;
        hasPatches = true;
      }
    });

    return hasPatches ? patches : null;
  }

  diffChildren(oldChildren, newChildren) {
    const patches = [];
    const maxLength = Math.max(oldChildren.length, newChildren.length);

    for (let i = 0; i < maxLength; i++) {
      patches.push(this.diff(oldChildren[i], newChildren[i]));
    }

    return patches;
  }

  patch(element, patches, vnode) {
    if (!patches) return;

    this.ignoreNextMutation = true;

    switch (patches.type) {
      case 'CREATE':
        element.appendChild(this.render(patches.newVNode));
        break;

      case 'REMOVE':
        element.remove();
        break;

      case 'REPLACE':
        const newElement = this.render(patches.newVNode);
        element.parentNode.replaceChild(newElement, element);
        break;

      case 'TEXT':
        element.textContent = patches.newVNode;
        break;

      case 'UPDATE':
        // Update props
        if (patches.propPatches) {
          this.patchProps(element, patches.propPatches);
        }

        // Update children
        if (patches.childPatches) {
          patches.childPatches.forEach((childPatch, i) => {
            if (childPatch) {
              this.patch(element.childNodes[i], childPatch, vnode.children[i]);
            }
          });
        }
        break;
    }
  }

  patchProps(element, propPatches) {
    Object.entries(propPatches).forEach(([key, value]) => {
      if (value === undefined) {
        element.removeAttribute(key);
      } else if (key.startsWith('on')) {
        // Handle event listeners
        const event = key.substring(2).toLowerCase();
        // Note: This is simplified - real implementation would track old listeners
      } else if (key === 'className') {
        element.className = value;
      } else if (key === 'style' && typeof value === 'object') {
        Object.assign(element.style, value);
      } else {
        element.setAttribute(key, value);
      }
    });
  }

  update(newVNode) {
    const patches = this.diff(this.vdom, newVNode);

    if (patches) {
      this.patch(this.container, patches, newVNode);
    }

    this.vdom = newVNode;
  }

  destroy() {
    if (this.observer) {
      this.observer.disconnect();
    }
  }
}

Content Security Monitoring

// Monitor for XSS attempts and unsafe content
class ContentSecurityMonitor {
  constructor(options = {}) {
    this.options = {
      checkScripts: true,
      checkIframes: true,
      checkLinks: true,
      checkStyles: true,
      whitelist: [],
      ...options,
    };

    this.observer = null;
    this.violations = [];

    this.init();
  }

  init() {
    this.observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === 'childList') {
          mutation.addedNodes.forEach((node) => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              this.checkElement(node);
              // Check all descendants
              node.querySelectorAll('*').forEach((child) => {
                this.checkElement(child);
              });
            } else if (node.nodeType === Node.TEXT_NODE) {
              this.checkTextNode(node);
            }
          });
        } else if (mutation.type === 'attributes') {
          this.checkAttribute(mutation);
        }
      });
    });

    this.observer.observe(document.body, {
      childList: true,
      attributes: true,
      subtree: true,
      attributeOldValue: true,
    });
  }

  checkElement(element) {
    const tagName = element.tagName.toLowerCase();

    // Check for script elements
    if (this.options.checkScripts && tagName === 'script') {
      if (!this.isWhitelisted(element.src)) {
        this.reportViolation('script', element);
        element.remove();
      }
    }

    // Check for iframes
    if (this.options.checkIframes && tagName === 'iframe') {
      if (!this.isWhitelisted(element.src)) {
        this.reportViolation('iframe', element);
        element.remove();
      }
    }

    // Check for suspicious links
    if (this.options.checkLinks && tagName === 'a') {
      const href = element.getAttribute('href');
      if (href && this.isSuspiciousLink(href)) {
        this.reportViolation('suspicious-link', element);
        element.removeAttribute('href');
      }
    }

    // Check for inline styles
    if (this.options.checkStyles && element.style.cssText) {
      if (this.containsSuspiciousStyles(element.style.cssText)) {
        this.reportViolation('suspicious-style', element);
        element.removeAttribute('style');
      }
    }

    // Check event handlers
    Array.from(element.attributes).forEach((attr) => {
      if (attr.name.startsWith('on')) {
        this.reportViolation('inline-event-handler', element, attr.name);
        element.removeAttribute(attr.name);
      }
    });
  }

  checkAttribute(mutation) {
    const { target, attributeName, oldValue } = mutation;
    const newValue = target.getAttribute(attributeName);

    // Check for javascript: URLs
    if (attributeName === 'href' || attributeName === 'src') {
      if (newValue && this.isSuspiciousLink(newValue)) {
        this.reportViolation('suspicious-attribute', target, attributeName);
        target.removeAttribute(attributeName);
        return;
      }
    }

    // Check for event handlers
    if (attributeName.startsWith('on')) {
      this.reportViolation('inline-event-handler', target, attributeName);
      target.removeAttribute(attributeName);
      return;
    }

    // Check style attribute
    if (attributeName === 'style' && this.options.checkStyles) {
      if (this.containsSuspiciousStyles(newValue)) {
        this.reportViolation('suspicious-style-attribute', target);
        target.removeAttribute('style');
      }
    }
  }

  checkTextNode(node) {
    const text = node.textContent;

    // Check for script tags in text
    if (/<script[\s>]/i.test(text)) {
      this.reportViolation('script-in-text', node.parentElement);
      node.textContent = text.replace(
        /<script[\s\S]*?<\/script>/gi,
        '[REMOVED]'
      );
    }
  }

  isWhitelisted(url) {
    if (!url) return false;

    return this.options.whitelist.some((pattern) => {
      if (pattern instanceof RegExp) {
        return pattern.test(url);
      }
      return url.startsWith(pattern);
    });
  }

  isSuspiciousLink(url) {
    const suspicious = [/^javascript:/i, /^data:.*script/i, /^vbscript:/i];

    return suspicious.some((pattern) => pattern.test(url));
  }

  containsSuspiciousStyles(styles) {
    const suspicious = [
      /expression\s*\(/i,
      /javascript:/i,
      /@import/i,
      /behavior:/i,
    ];

    return suspicious.some((pattern) => pattern.test(styles));
  }

  reportViolation(type, element, details = '') {
    const violation = {
      type,
      timestamp: Date.now(),
      element: element.outerHTML.substring(0, 200),
      tagName: element.tagName,
      details,
      location: this.getElementPath(element),
    };

    this.violations.push(violation);

    console.warn('Security violation detected:', violation);

    // Dispatch event
    window.dispatchEvent(
      new CustomEvent('security-violation', {
        detail: violation,
      })
    );
  }

  getElementPath(element) {
    const path = [];
    let current = element;

    while (current && current !== document.body) {
      let selector = current.tagName.toLowerCase();

      if (current.id) {
        selector += `#${current.id}`;
      } else if (current.className) {
        selector += `.${current.className.split(' ').join('.')}`;
      }

      path.unshift(selector);
      current = current.parentElement;
    }

    return path.join(' > ');
  }

  getViolations() {
    return [...this.violations];
  }

  clearViolations() {
    this.violations = [];
  }

  destroy() {
    if (this.observer) {
      this.observer.disconnect();
    }
  }
}

Live Collection Updates

// Keep live collections synchronized with DOM changes
class LiveCollectionManager {
  constructor() {
    this.collections = new Map();
    this.observer = null;

    this.init();
  }

  init() {
    this.observer = new MutationObserver((mutations) => {
      const affectedSelectors = new Set();

      mutations.forEach((mutation) => {
        // Determine which selectors might be affected
        this.collections.forEach((collection, selector) => {
          if (this.mutationAffectsSelector(mutation, selector)) {
            affectedSelectors.add(selector);
          }
        });
      });

      // Update affected collections
      affectedSelectors.forEach((selector) => {
        this.updateCollection(selector);
      });
    });

    this.observer.observe(document.body, {
      childList: true,
      attributes: true,
      subtree: true,
      attributeFilter: ['class', 'id', 'data-*'],
    });
  }

  createLiveCollection(selector, onChange) {
    const collection = {
      selector,
      elements: new Set(),
      onChange,
      callbacks: new Set(),
    };

    this.collections.set(selector, collection);
    this.updateCollection(selector);

    return {
      get elements() {
        return Array.from(collection.elements);
      },

      get size() {
        return collection.elements.size;
      },

      forEach(callback) {
        collection.elements.forEach(callback);
      },

      subscribe(callback) {
        collection.callbacks.add(callback);
        return () => collection.callbacks.delete(callback);
      },

      destroy() {
        this.collections.delete(selector);
      },
    };
  }

  updateCollection(selector) {
    const collection = this.collections.get(selector);
    if (!collection) return;

    const oldElements = new Set(collection.elements);
    const newElements = new Set(document.querySelectorAll(selector));

    const added = [];
    const removed = [];

    // Find added elements
    newElements.forEach((element) => {
      if (!oldElements.has(element)) {
        added.push(element);
      }
    });

    // Find removed elements
    oldElements.forEach((element) => {
      if (!newElements.has(element)) {
        removed.push(element);
      }
    });

    // Update collection
    collection.elements = newElements;

    // Notify if changed
    if (added.length > 0 || removed.length > 0) {
      const change = { added, removed, elements: Array.from(newElements) };

      if (collection.onChange) {
        collection.onChange(change);
      }

      collection.callbacks.forEach((callback) => {
        callback(change);
      });
    }
  }

  mutationAffectsSelector(mutation, selector) {
    // This is a simplified check - a real implementation would use a CSS parser
    if (mutation.type === 'childList') {
      return true; // Any DOM structure change could affect any selector
    }

    if (mutation.type === 'attributes') {
      const { target, attributeName } = mutation;

      // Check if selector uses this attribute
      if (attributeName === 'class' && selector.includes('.')) {
        return true;
      }
      if (attributeName === 'id' && selector.includes('#')) {
        return true;
      }
      if (
        attributeName.startsWith('data-') &&
        selector.includes(`[${attributeName}`)
      ) {
        return true;
      }
    }

    return false;
  }

  destroy() {
    if (this.observer) {
      this.observer.disconnect();
    }
    this.collections.clear();
  }
}

// Usage example
const collectionManager = new LiveCollectionManager();

const activeButtons = collectionManager.createLiveCollection(
  'button.active',
  (change) => {
    console.log('Active buttons changed:', change);
    console.log(
      `Added: ${change.added.length}, Removed: ${change.removed.length}`
    );
  }
);

// Subscribe to changes
const unsubscribe = activeButtons.subscribe((change) => {
  console.log(`Now ${change.elements.length} active buttons`);
});

Performance Optimization

Efficient Observer Patterns

// Debounced mutation handling
class DebouncedMutationObserver {
  constructor(callback, delay = 100) {
    this.callback = callback;
    this.delay = delay;
    this.mutations = [];
    this.timeout = null;

    this.observer = new MutationObserver((mutations) => {
      this.mutations.push(...mutations);
      this.scheduleCallback();
    });
  }

  scheduleCallback() {
    if (this.timeout) {
      clearTimeout(this.timeout);
    }

    this.timeout = setTimeout(() => {
      const mutations = this.mutations.slice();
      this.mutations = [];
      this.callback(mutations);
    }, this.delay);
  }

  observe(target, config) {
    this.observer.observe(target, config);
  }

  disconnect() {
    if (this.timeout) {
      clearTimeout(this.timeout);
    }
    this.observer.disconnect();
  }

  takeRecords() {
    return this.observer.takeRecords();
  }
}

// Selective observation
class SelectiveMutationObserver {
  constructor() {
    this.observers = new Map();
    this.globalObserver = null;
  }

  observeSelector(selector, callback, options = {}) {
    const config = {
      childList: true,
      subtree: true,
      ...options,
    };

    // Find all matching elements
    const elements = document.querySelectorAll(selector);

    elements.forEach((element) => {
      if (!this.observers.has(element)) {
        const observer = new MutationObserver(callback);
        observer.observe(element, config);
        this.observers.set(element, { observer, selector, config });
      }
    });

    // Watch for new elements matching selector
    if (!this.globalObserver) {
      this.globalObserver = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          if (mutation.type === 'childList') {
            mutation.addedNodes.forEach((node) => {
              if (node.nodeType === Node.ELEMENT_NODE) {
                if (node.matches(selector)) {
                  this.observeElement(node, selector, callback, config);
                }
                node.querySelectorAll(selector).forEach((el) => {
                  this.observeElement(el, selector, callback, config);
                });
              }
            });
          }
        });
      });

      this.globalObserver.observe(document.body, {
        childList: true,
        subtree: true,
      });
    }
  }

  observeElement(element, selector, callback, config) {
    if (!this.observers.has(element)) {
      const observer = new MutationObserver(callback);
      observer.observe(element, config);
      this.observers.set(element, { observer, selector, config });
    }
  }

  unobserveSelector(selector) {
    this.observers.forEach((data, element) => {
      if (data.selector === selector) {
        data.observer.disconnect();
        this.observers.delete(element);
      }
    });
  }

  disconnect() {
    this.observers.forEach((data) => {
      data.observer.disconnect();
    });
    this.observers.clear();

    if (this.globalObserver) {
      this.globalObserver.disconnect();
      this.globalObserver = null;
    }
  }
}

Best Practices

  1. Be specific with observation config
// Good - observe only what you need
observer.observe(element, {
  attributes: true,
  attributeFilter: ['class', 'style'],
});

// Avoid - observing everything
observer.observe(element, {
  attributes: true,
  childList: true,
  characterData: true,
  subtree: true,
});
  1. Disconnect observers when not needed
// Good - cleanup
class Component {
  connectedCallback() {
    this.observer = new MutationObserver(this.handleMutations);
    this.observer.observe(this, config);
  }

  disconnectedCallback() {
    this.observer.disconnect();
  }
}
  1. Batch mutations when possible
// Good - batch DOM updates
const fragment = document.createDocumentFragment();
items.forEach((item) => {
  fragment.appendChild(createItem(item));
});
container.appendChild(fragment);

// Avoid - individual updates
items.forEach((item) => {
  container.appendChild(createItem(item));
});
  1. Use takeRecords() for synchronous processing
// Process pending mutations immediately
const records = observer.takeRecords();
if (records.length > 0) {
  processMutations(records);
}
observer.disconnect();

Conclusion

The Mutation Observer API is a powerful tool for monitoring DOM changes efficiently. It enables reactive programming patterns, dynamic component initialization, and content validation without the performance penalties of older approaches. By understanding its capabilities and following best practices, you can build robust applications that respond intelligently to DOM mutations while maintaining excellent performance.