V2. Mount Observing Transforms - bahrus/trans-render GitHub Wiki

Mount-Observing Transforms v2

Evolution: From Manual Transforms to Declarative Enhancements

Mount-observing transforms v2 represents a paradigm shift in how we think about binding from a distance. Building on the foundation of mount-observer and assign-gingerly, this approach embraces:

  1. Declarative attribute parsing - Let HTML attributes drive configuration
  2. Enhancement-based architecture - Spawn class instances that manage DOM fragments
  3. Automatic lifecycle management - Built-in disposal and async resolution
  4. Registry-scoped enhancements - Leverage scoped custom element registries

Core Concepts

The Enhancement Pattern

Instead of imperatively transforming DOM elements, we enhance them by spawning class instances that manage their behavior and state. These enhancements:

  • Are spawned automatically when elements mount
  • Parse their configuration from HTML attributes
  • Manage their own lifecycle (initialization, updates, disposal)
  • Can be async-aware with built-in resolution tracking

Mount Observer Integration

Mount-observer watches for elements matching specific attribute patterns and automatically spawns enhancements when they appear:

import { MountObserver } from 'mount-observer';
import { buildCSSQuery } from 'assign-gingerly';

const enhancementConfig = {
  spawn: MyEnhancement,
  enhKey: 'myEnh',
  withAttrs: {
    base: 'my-enh',
    theme: '${base}-theme',
    count: '${base}-count',
    _count: { instanceOf: 'Number' }
  }
};

// Build CSS query from attribute patterns
const matching = buildCSSQuery(enhancementConfig);

// Watch for matching elements
const observer = new MountObserver({
  matching,
  do: (element) => {
    // Automatically spawn and configure enhancement
    element.enh.get(enhancementConfig);
  }
});

Example 1: Simple Property Binding

HTML:

<div my-enh-greeting="Hello, World!" my-enh-count="42">
  <span class="greeting"></span>
  <span class="count"></span>
</div>

Enhancement:

class GreetingEnhancement {
  element;
  greeting = '';
  count = 0;
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      this.render();
    }
  }
  
  render() {
    const greetingEl = this.element.querySelector('.greeting');
    const countEl = this.element.querySelector('.count');
    
    if (greetingEl) greetingEl.textContent = this.greeting;
    if (countEl) countEl.textContent = this.count.toLocaleString();
  }
}

// Register enhancement
const registry = document.customElementRegistry.enhancementRegistry;
registry.push({
  spawn: GreetingEnhancement,
  enhKey: 'greetingEnh',
  withAttrs: {
    base: 'my-enh',
    greeting: '${base}-greeting',
    count: '${base}-count',
    _count: { instanceOf: 'Number' }
  }
});

// Mount observer automatically spawns when element appears
const observer = new MountObserver({
  matching: buildCSSQuery(registry.getItems()[0]),
  do: (el) => el.enh.get(registry.getItems()[0])
});

Example 2: The enh- Prefix for Isolation

For custom elements and SVG, use the enh- prefix to avoid attribute conflicts:

HTML:

<my-widget enh-data-theme="dark" enh-data-size="large">
  <div class="content"></div>
</my-widget>

Enhancement:

class WidgetEnhancement {
  element;
  theme = 'light';
  size = 'medium';
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      this.applyTheme();
    }
  }
  
  applyTheme() {
    this.element.dataset.theme = this.theme;
    this.element.dataset.size = this.size;
  }
}

registry.push({
  spawn: WidgetEnhancement,
  enhKey: 'widgetEnh',
  withAttrs: {
    base: 'data-',
    theme: '${base}theme',
    size: '${base}size'
  }
});

The enh- prefix ensures widget's own data-theme attribute doesn't conflict with enhancement configuration.

Example 3: Reactive Updates with assignGingerly

Update enhancement properties reactively using assignGingerly:

HTML:

<div id="counter" enh-counter-value="0">
  <button class="decrement">-</button>
  <span class="value"></span>
  <button class="increment">+</button>
</div>

Enhancement:

class CounterEnhancement {
  element;
  value = 0;
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
    }
    this.attachListeners();
    this.render();
  }
  
  attachListeners() {
    this.element.querySelector('.increment')?.addEventListener('click', () => {
      this.element.assignGingerly({
        enh: {
          counterEnh: { value: this.value + 1 }
        }
      });
    });
    
    this.element.querySelector('.decrement')?.addEventListener('click', () => {
      this.element.assignGingerly({
        enh: {
          counterEnh: { value: this.value - 1 }
        }
      });
    });
  }
  
  render() {
    const valueEl = this.element.querySelector('.value');
    if (valueEl) valueEl.textContent = this.value;
  }
}

registry.push({
  spawn: CounterEnhancement,
  enhKey: 'counterEnh',
  withAttrs: {
    base: 'counter-',
    value: '${base}value',
    _value: { instanceOf: 'Number' }
  }
});

Example 4: Nested Object Binding with Itemscope

Use microdata's itemscope and itemprop for nested data structures:

HTML:

<div itemscope="user-profile" enh-user-name="Alice">
  <div itemscope itemprop="address" enh-address-city="Seattle" enh-address-zip="98101">
    <span itemprop="city"></span>
    <span itemprop="zip"></span>
  </div>
</div>

Enhancement:

class AddressEnhancement {
  element;
  city = '';
  zip = '';
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      this.render();
    }
  }
  
  render() {
    const cityEl = this.element.querySelector('[itemprop="city"]');
    const zipEl = this.element.querySelector('[itemprop="zip"]');
    
    if (cityEl) cityEl.textContent = this.city;
    if (zipEl) zipEl.textContent = this.zip;
  }
}

class UserProfileManager {
  element;
  name = '';
  
  constructor(element, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
    }
  }
}

// Register nested enhancement
registry.push({
  spawn: AddressEnhancement,
  enhKey: 'addressEnh',
  withAttrs: {
    base: 'address-',
    city: '${base}city',
    zip: '${base}zip'
  }
});

// Register itemscope manager
customElements.itemscopeRegistry.define('user-profile', {
  manager: UserProfileManager
});

Example 5: Async Initialization with Lifecycle Keys

Handle async data loading with built-in lifecycle support:

HTML:

<div enh-data-loader-url="/api/users">
  <div class="loading">Loading...</div>
  <div class="content" hidden></div>
  <div class="error" hidden></div>
</div>

Enhancement:

class DataLoaderEnhancement extends EventTarget {
  element;
  url = '';
  resolved = false;
  data = null;
  error = null;
  
  constructor(element, ctx, initVals) {
    super();
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      this.load();
    }
  }
  
  async load() {
    try {
      const response = await fetch(this.url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      
      this.data = await response.json();
      this.resolved = true;
      this.dispatchEvent(new Event('resolved'));
      this.render();
    } catch (err) {
      this.error = err.message;
      this.renderError();
    }
  }
  
  render() {
    const loading = this.element.querySelector('.loading');
    const content = this.element.querySelector('.content');
    
    if (loading) loading.hidden = true;
    if (content) {
      content.hidden = false;
      content.textContent = JSON.stringify(this.data, null, 2);
    }
  }
  
  renderError() {
    const loading = this.element.querySelector('.loading');
    const error = this.element.querySelector('.error');
    
    if (loading) loading.hidden = true;
    if (error) {
      error.hidden = false;
      error.textContent = this.error;
    }
  }
  
  dispose() {
    // Cleanup if needed
    this.data = null;
  }
}

const config = {
  spawn: DataLoaderEnhancement,
  enhKey: 'dataLoader',
  lifecycleKeys: true, // Use standard 'resolved' and 'dispose'
  withAttrs: {
    base: 'data-loader-',
    url: '${base}url'
  }
};

registry.push(config);

// Wait for async initialization
const element = document.querySelector('[enh-data-loader-url]');
const instance = await element.enh.whenResolved(config);
console.log('Data loaded:', instance.data);

Example 6: Conditional Spawning with canSpawn

Restrict enhancements to specific element types or conditions:

HTML:

<button enh-ripple-effect>Click Me</button>
<div enh-ripple-effect>Not a button</div>

Enhancement:

class RippleEffectEnhancement {
  element;
  
  constructor(element, ctx, initVals) {
    this.element = element;
    this.attachRipple();
  }
  
  static canSpawn(element, ctx) {
    // Only spawn on button elements
    return element.tagName?.toLowerCase() === 'button';
  }
  
  attachRipple() {
    this.element.addEventListener('click', (e) => {
      const ripple = document.createElement('span');
      ripple.className = 'ripple';
      ripple.style.left = `${e.offsetX}px`;
      ripple.style.top = `${e.offsetY}px`;
      this.element.appendChild(ripple);
      
      setTimeout(() => ripple.remove(), 600);
    });
  }
}

registry.push({
  spawn: RippleEffectEnhancement,
  enhKey: 'rippleEnh',
  withAttrs: {
    base: 'ripple-effect'
  }
});

Only the <button> will get the ripple effect; the <div> is silently skipped.

Example 7: Method Calls with withMethods

Invoke methods during property assignment:

HTML:

<div enh-class-manager-add="active highlight">
  Content
</div>

Enhancement:

class ClassManagerEnhancement {
  element;
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
    }
  }
}

// Update element to call classList.add
element.assignGingerly({
  '?.classList?.add': ['active', 'highlight']
}, { withMethods: ['add'] });

Example 8: Custom Parsers for Complex Attributes

Parse custom attribute formats with named parsers:

HTML:

<div enh-config-created="2024-01-15T10:30:00Z" enh-config-tags="web,components,enhancement">
  Content
</div>

Enhancement:

import { globalParserRegistry } from 'assign-gingerly';

// Register custom parsers
globalParserRegistry.register('timestamp', (v) => 
  v ? new Date(v).getTime() : null
);

globalParserRegistry.register('csv', (v) => 
  v ? v.split(',').map(s => s.trim()) : []
);

class ConfigEnhancement {
  element;
  created = null;
  tags = [];
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      console.log('Created timestamp:', this.created);
      console.log('Tags array:', this.tags);
    }
  }
}

registry.push({
  spawn: ConfigEnhancement,
  enhKey: 'configEnh',
  withAttrs: {
    base: 'config-',
    created: '${base}created',
    _created: { parser: 'timestamp' },
    tags: '${base}tags',
    _tags: { parser: 'csv' }
  }
});

Example 9: Default Values with valIfNull

Provide fallback values for missing attributes:

HTML:

<div enh-theme-mode="dark">
  <!-- size attribute missing, will use default -->
</div>

Enhancement:

class ThemeEnhancement {
  element;
  mode = 'light';
  size = 'medium';
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      this.applyTheme();
    }
  }
  
  applyTheme() {
    this.element.dataset.mode = this.mode;
    this.element.dataset.size = this.size;
  }
}

registry.push({
  spawn: ThemeEnhancement,
  enhKey: 'themeEnh',
  withAttrs: {
    base: 'theme-',
    mode: '${base}mode',
    _mode: { valIfNull: 'light' },
    size: '${base}size',
    _size: { valIfNull: 'medium' }
  }
});

Example 10: Performance Optimization with parseCache

Cache parsed attribute values for repeated patterns:

HTML:

<div enh-settings='{"theme":"dark","lang":"en"}'>Item 1</div>
<div enh-settings='{"theme":"dark","lang":"en"}'>Item 2</div>
<div enh-settings='{"theme":"dark","lang":"en"}'>Item 3</div>

Enhancement:

class SettingsEnhancement {
  element;
  settings = {};
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      // Don't mutate settings - it's shared!
      console.log('Settings:', this.settings);
    }
  }
}

registry.push({
  spawn: SettingsEnhancement,
  enhKey: 'settingsEnh',
  withAttrs: {
    base: 'settings',
    _base: {
      instanceOf: 'Object',
      parseCache: 'shared' // Parse once, reuse for all elements
    }
  }
});

Example 11: List Management with Itemscope Managers

Manage repeated DOM fragments with itemscope managers:

HTML:

<ul itemscope="todo-list">
  <li itemscope="todo-item" enh-todo-text="Buy milk" enh-todo-done="false">
    <input type="checkbox">
    <span class="text"></span>
  </li>
  <li itemscope="todo-item" enh-todo-text="Walk dog" enh-todo-done="true">
    <input type="checkbox" checked>
    <span class="text"></span>
  </li>
</ul>

Enhancement:

class TodoItemManager {
  element;
  text = '';
  done = false;
  
  constructor(element, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      this.render();
      this.attachListeners();
    }
  }
  
  render() {
    const checkbox = this.element.querySelector('input[type="checkbox"]');
    const textEl = this.element.querySelector('.text');
    
    if (checkbox) checkbox.checked = this.done;
    if (textEl) {
      textEl.textContent = this.text;
      textEl.style.textDecoration = this.done ? 'line-through' : 'none';
    }
  }
  
  attachListeners() {
    const checkbox = this.element.querySelector('input[type="checkbox"]');
    if (checkbox) {
      checkbox.addEventListener('change', (e) => {
        this.element.assignGingerly({
          ish: { done: e.target.checked }
        });
      });
    }
  }
  
  cleanup() {
    console.log('Cleaning up todo item');
  }
}

class TodoListManager {
  element;
  items = [];
  
  constructor(element, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
    }
  }
  
  addItem(text) {
    const li = document.createElement('li');
    li.setAttribute('itemscope', 'todo-item');
    li.innerHTML = `
      <input type="checkbox">
      <span class="text"></span>
    `;
    this.element.appendChild(li);
    
    li.assignGingerly({
      ish: { text, done: false }
    });
  }
}

// Register itemscope managers
customElements.itemscopeRegistry.define('todo-item', {
  manager: TodoItemManager,
  lifecycleKeys: {
    dispose: 'cleanup'
  }
});

customElements.itemscopeRegistry.define('todo-list', {
  manager: TodoListManager
});

// Wait for all items to initialize
await customElements.itemscopeRegistry.whenDefined('todo-item');

Example 12: Dynamic Updates and Disposal

Manage enhancement lifecycle explicitly:

HTML:

<div id="dynamic-content" enh-content-source="/api/content">
  <div class="placeholder">Loading...</div>
</div>

Enhancement:

class ContentEnhancement extends EventTarget {
  element;
  source = '';
  resolved = false;
  abortController = null;
  
  constructor(element, ctx, initVals) {
    super();
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      this.load();
    }
  }
  
  async load() {
    this.abortController = new AbortController();
    
    try {
      const response = await fetch(this.source, {
        signal: this.abortController.signal
      });
      const html = await response.text();
      
      this.element.innerHTML = html;
      this.resolved = true;
      this.dispatchEvent(new Event('resolved'));
    } catch (err) {
      if (err.name !== 'AbortError') {
        console.error('Load failed:', err);
      }
    }
  }
  
  dispose() {
    // Cancel pending fetch
    if (this.abortController) {
      this.abortController.abort();
      this.abortController = null;
    }
  }
}

const config = {
  spawn: ContentEnhancement,
  enhKey: 'contentEnh',
  lifecycleKeys: true,
  withAttrs: {
    base: 'content-',
    source: '${base}source'
  }
};

registry.push(config);

// Later, dispose when no longer needed
const element = document.getElementById('dynamic-content');
element.enh.dispose(config);

Example 13: Combining Multiple Enhancements

Apply multiple enhancements to the same element:

HTML:

<button 
  enh-ripple-effect
  enh-tooltip-text="Click to submit"
  enh-analytics-event="button-click">
  Submit
</button>

Enhancements:

class RippleEnhancement {
  element;
  constructor(element, ctx, initVals) {
    this.element = element;
    this.attachRipple();
  }
  
  attachRipple() {
    // Ripple effect implementation
  }
}

class TooltipEnhancement {
  element;
  text = '';
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      this.attachTooltip();
    }
  }
  
  attachTooltip() {
    this.element.title = this.text;
  }
}

class AnalyticsEnhancement {
  element;
  event = '';
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      this.trackClicks();
    }
  }
  
  trackClicks() {
    this.element.addEventListener('click', () => {
      console.log('Analytics event:', this.event);
    });
  }
}

// Register all enhancements
registry.push([
  {
    spawn: RippleEnhancement,
    enhKey: 'rippleEnh',
    withAttrs: { base: 'ripple-effect' }
  },
  {
    spawn: TooltipEnhancement,
    enhKey: 'tooltipEnh',
    withAttrs: {
      base: 'tooltip-',
      text: '${base}text'
    }
  },
  {
    spawn: AnalyticsEnhancement,
    enhKey: 'analyticsEnh',
    withAttrs: {
      base: 'analytics-',
      event: '${base}event'
    }
  }
]);

// Mount observer spawns all matching enhancements
const observer = new MountObserver({
  matching: registry.getItems().map(buildCSSQuery).join(', '),
  do: (element) => {
    registry.getItems().forEach(config => {
      const query = buildCSSQuery(config);
      if (element.matches(query)) {
        element.enh.get(config);
      }
    });
  }
});

Best Practices

1. Use Semantic Attributes

Choose attribute names that clearly describe their purpose:

<!-- Good -->
<div enh-user-profile-name="Alice" enh-user-profile-role="admin">

<!-- Avoid -->
<div enh-up-n="Alice" enh-up-r="admin">

2. Leverage Template Variables

Build hierarchical attribute patterns:

withAttrs: {
  base: 'data-',
  app: '${base}app',
  user: '${app}-user',
  profile: '${user}-profile',
  name: '${profile}-name'
}
// Resolves to: data-app-user-profile-name

3. Prefer enh- Prefix for Custom Elements

Avoid conflicts with element's own attributes:

<my-widget enh-config-theme="dark">

4. Implement Proper Disposal

Always clean up resources in dispose methods:

dispose() {
  // Remove event listeners
  if (this.boundHandler) {
    window.removeEventListener('resize', this.boundHandler);
  }
  
  // Clear timers
  if (this.timerId) {
    clearInterval(this.timerId);
  }
  
  // Clear references
  this.element = null;
}

5. Use Lifecycle Keys for Async Operations

Extend EventTarget and implement resolved pattern:

class AsyncEnhancement extends EventTarget {
  resolved = false;
  
  async initialize() {
    // Async work
    await this.loadData();
    
    // Mark as resolved
    this.resolved = true;
    this.dispatchEvent(new Event('resolved'));
  }
}

6. Cache Parsed Values When Appropriate

Use parseCache for repeated attribute values:

withAttrs: {
  config: 'data-config',
  _config: {
    instanceOf: 'Object',
    parseCache: 'cloned' // Safe for mutable data
  }
}

7. Validate with canSpawn

Restrict enhancements to appropriate elements:

static canSpawn(element, ctx) {
  return element.tagName?.toLowerCase() === 'button' &&
         !element.disabled;
}

Migration from v1

Before (v1 Transform syntax):

Transform<Model>(div, model, {
  span: {
    o: ['greeting'],
    d: 0
  }
});

After (v2 Enhancement pattern):

<div enh-greeter-text="Hello">
  <span class="output"></span>
</div>
class GreeterEnhancement {
  element;
  text = '';
  
  constructor(element, ctx, initVals) {
    this.element = element;
    if (initVals) {
      Object.assign(this, initVals);
      this.render();
    }
  }
  
  render() {
    const output = this.element.querySelector('.output');
    if (output) output.textContent = this.text;
  }
}

registry.push({
  spawn: GreeterEnhancement,
  enhKey: 'greeterEnh',
  withAttrs: {
    base: 'greeter-',
    text: '${base}text'
  }
});

Conclusion

Mount-observing transforms v2 embraces a more declarative, enhancement-based approach that:

  • Reduces boilerplate through automatic attribute parsing
  • Improves maintainability with class-based enhancements
  • Provides better lifecycle management
  • Scales naturally with mount-observer's CSS-like matching
  • Leverages platform features (scoped registries, microdata)

The result is cleaner, more maintainable code that feels natural to web developers familiar with HTML attributes and CSS selectors.

⚠️ **GitHub.com Fallback** ⚠️