V2. Mount Observing Transforms - bahrus/trans-render GitHub Wiki
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:
- Declarative attribute parsing - Let HTML attributes drive configuration
- Enhancement-based architecture - Spawn class instances that manage DOM fragments
- Automatic lifecycle management - Built-in disposal and async resolution
- Registry-scoped enhancements - Leverage scoped custom element registries
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 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);
}
});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])
});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.
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' }
}
});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
});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);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.
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'] });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' }
}
});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' }
}
});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
}
}
});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');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);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);
}
});
}
});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">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-nameAvoid conflicts with element's own attributes:
<my-widget enh-config-theme="dark">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;
}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'));
}
}Use parseCache for repeated attribute values:
withAttrs: {
config: 'data-config',
_config: {
instanceOf: 'Object',
parseCache: 'cloned' // Safe for mutable data
}
}Restrict enhancements to appropriate elements:
static canSpawn(element, ctx) {
return element.tagName?.toLowerCase() === 'button' &&
!element.disabled;
}Transform<Model>(div, model, {
span: {
o: ['greeting'],
d: 0
}
});<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'
}
});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.