hyper-morph
v0.2.0
Published
content-based DOM morphing library
Downloads
8
Readme
HyperMorph
Content-based DOM morphing. An enhanced Idiomorph that preserves element identity without explicit IDs.
The Problem
Positional matching fails on reorders and prepends:
<!-- Before --> <!-- After -->
<ul> <ul>
<li>Apple</li> ← position 0 <li>NEW</li> ← position 0
<li>Banana</li> ← position 1 <li>Apple</li> ← position 1
</ul> <li>Banana</li> ← position 2
</ul>Positional morph: "Apple" DOM node gets text changed to "NEW". Focus lost, animations break, state resets.
HyperMorph: Recognizes "Apple" moved to position 1, preserves the DOM node.
How It Works
┌─────────────────────────────────────────────────────────────────┐
│ MATCHING ALGORITHM │
├─────────────────────────────────────────────────────────────────┤
│ │
│ OLD TREE NEW TREE │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ sig: a3f │◄──── SIGNATURE ─────►│ sig: a3f │ │
│ │ path: #m │ LOOKUP │ path: #m │ │
│ └──────────┘ └──────────┘ │
│ │ │ │
│ └──────────► SCORE PAIR ◄──────────┘ │
│ ┌──────────┐ │
│ │ sig=+100 │ │
│ │path=+30 │ │
│ │text=+20 │ │
│ │conf=150 │ │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ confidence ≥ 101? │
│ YES → MATCH (move DOM node) │
│ NO → RECREATE │
│ │
└─────────────────────────────────────────────────────────────────┘Signature = hash of tag + classes + key attributes Path = structural address relative to landmarks (IDs, roles, semantic tags) Threshold = 101 (signature alone isn't enough; requires additional signal)
Installation
npm install hyper-morphUsage
import HyperMorph from 'hyper-morph';
// Basic morph
HyperMorph.morph(oldElement, newContent);
// With options
HyperMorph.morph(oldElement, newContent, {
morphStyle: 'innerHTML',
callbacks: {
beforeNodeMorphed: (oldNode, newNode) => console.log('morphing', oldNode)
}
});Configuration Reference
Top-Level Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| morphStyle | 'outerHTML' \| 'innerHTML' | 'outerHTML' | Replace element itself or just its children |
| ignoreActive | boolean | false | Skip morphing the focused element entirely |
| ignoreActiveValue | boolean | false | Preserve value of focused input/textarea |
| restoreFocus | boolean | true | Restore focus and selection after morph |
HyperMorph.morph(el, html, {
morphStyle: 'innerHTML',
ignoreActive: false,
ignoreActiveValue: true,
restoreFocus: true
});Callbacks
Hook into the morph lifecycle. Return false from "before" callbacks to prevent the action.
| Callback | Signature | Description |
|----------|-----------|-------------|
| beforeNodeAdded | (node) => boolean | Before inserting new node. Return false to skip. |
| afterNodeAdded | (node) => void | After node inserted |
| beforeNodeMorphed | (oldNode, newNode) => boolean | Before morphing. Return false to skip. |
| afterNodeMorphed | (oldNode, newNode) => void | After node morphed |
| beforeNodeRemoved | (node) => boolean | Before removing. Return false to keep. |
| afterNodeRemoved | (node) => void | After node removed |
| beforeAttributeUpdated | (attr, el, type) => boolean | Before attribute change. type is 'update' or 'remove'. |
HyperMorph.morph(el, html, {
callbacks: {
beforeNodeAdded: (node) => {
if (node.classList?.contains('skip')) return false;
},
afterNodeMorphed: (oldNode, newNode) => {
console.log('Morphed:', oldNode);
},
beforeAttributeUpdated: (attr, el, type) => {
if (attr === 'data-persist') return false; // prevent update
}
}
});Head Configuration
Control how <head> elements are handled during full-document morphs.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| style | 'merge' \| 'append' \| 'morph' \| 'none' | 'merge' | Merge strategy for head elements |
| block | boolean | false | Wait for new stylesheets/scripts to load before morphing body |
| ignore | boolean | false | Skip head morphing entirely |
| shouldPreserve | (el) => boolean | Check im-preserve | Keep element even if not in new content |
| shouldReAppend | (el) => boolean | Check im-re-append | Remove and re-add (re-executes scripts) |
| shouldRemove | (el) => boolean | () => {} | Return false to prevent removal |
| afterHeadMorphed | (head, {added, kept, removed}) => void | noop | Called after head processing |
Head styles:
'merge'— Add new elements, remove old ones not in new content'append'— Only add new elements, never remove existing'morph'— Treat head like body (standard element morphing)'none'— Skip head entirely
HyperMorph.morph(document, newHtml, {
head: {
style: 'merge',
block: true, // wait for CSS to load
shouldPreserve: (el) => el.id === 'critical-styles',
afterHeadMorphed: (head, { added, kept, removed }) => {
console.log(`Added ${added.length}, kept ${kept.length}, removed ${removed.length}`);
}
}
});Scripts Configuration
Control how <script> elements in body are handled. Disabled by default to preserve backwards compatibility.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| handle | boolean | false | Enable special script handling |
| shouldPreserve | (el) => boolean | Check im-preserve | Keep script even if not in new content |
| shouldReAppend | (el) => boolean | Check im-re-append | Force re-execution of existing script |
| shouldRemove | (el) => boolean | () => {} | Return false to prevent removal |
| afterScriptsHandled | (container, {added, kept, removed}) => void | noop | Called after script processing |
Script behavior when handle: true:
- Same script exists (matching
outerHTML) → Preserved, not re-executed - New script → Executed via
createContextualFragment - External script (
src) → Waits for load event before resolving
HyperMorph.morph(el, html, {
scripts: {
handle: true,
shouldReAppend: (el) => el.dataset.reload === 'true',
afterScriptsHandled: (container, { added }) => {
console.log('Executed scripts:', added.length);
}
}
});Returns a Promise when scripts need to load:
await HyperMorph.morph(el, html, { scripts: { handle: true } });HTML Attributes
Control element behavior via HTML attributes:
| Attribute | Effect |
|-----------|--------|
| im-preserve="true" | Keep element even if removed from new content |
| im-re-append="true" | Force re-insertion (re-executes scripts) |
<!-- This script will re-execute on every morph -->
<script im-re-append="true">
console.log('Re-executed!');
</script>
<!-- This stylesheet persists even if not in new HTML -->
<link rel="stylesheet" href="critical.css" im-preserve="true">Matching Priority
- ID match — Elements with matching
idin subtree (Idiomorph's original logic) - HyperMorph — Content-based matching for anonymous elements
- Soft match — Same tag/nodeType as fallback
Elements with id attributes are excluded from HyperMorph and handled by ID-based logic.
Scoring Model
| Factor | Score | Description | |--------|-------|-------------| | Signature match | +100 | Required. Same tag + classes + key attributes | | Path segment | +10 each | Matching ancestors (max 4 segments) | | Text match | +20 | Element's text content matches | | Text mismatch | -25 | Text differs or one has text, other doesn't | | Unique candidate | +50 | Only one element with this signature (when text matches) | | Position drift | -1 per | Index difference between old and new position |
Acceptance threshold: ≥ 101 Signature match alone (100) isn't sufficient. Requires at least one additional signal.
Standalone Matcher
Use the matching algorithm independently:
import { createMatcher } from 'hyper-morph/matcher';
const matcher = createMatcher();
const { computeMatches, findMatch, explain } = matcher.session();
// Find all matches between two trees
const matches = computeMatches(oldRoot, newRoot);
// Returns Map<newElement, oldElement>
// Find match for a single element
const match = findMatch(newElement, oldRoot);
if (match) {
console.log(match.element); // The matching old element
console.log(match.confidence); // Score (101+)
console.log(match.breakdown); // Scoring details
}
// Debug why elements do/don't match
const result = explain(newEl, oldEl);
console.log(result.matches); // boolean
console.log(result.score); // number
console.log(result.breakdown); // { signature, path, text, ... }Matcher Configuration
const matcher = createMatcher({
includeClasses: true,
includeAttributes: ['href', 'src', 'name', 'type', 'role', 'aria-label', 'alt', 'title'],
excludeAttributePrefixes: ['data-morph-', 'data-hyper-', 'data-im-'],
textHintLength: 64,
excludeIds: true,
maxPathDepth: 4,
landmarks: ['HEADER', 'NAV', 'MAIN', 'ASIDE', 'FOOTER', 'SECTION', 'ARTICLE'],
weights: {
signature: 100,
pathSegment: 10,
textMatch: 20,
textMismatch: 25,
uniqueCandidate: 50,
positionPenalty: 1,
},
minConfidence: 101,
});DOM APIs Used
moveBefore— New DOM API (Chrome 131+, Firefox 133+) that moves elements without triggering lifecycle callbacks. Preserves iframe state, video playback, CSS animations.insertBefore— Fallback for browsers withoutmoveBefore.
Defaults
Access and modify global defaults:
import HyperMorph from 'hyper-morph';
// Read defaults
console.log(HyperMorph.defaults);
// Modify globally
HyperMorph.defaults.morphStyle = 'innerHTML';
HyperMorph.defaults.restoreFocus = false;Development
npm install
npm test # Run all tests
npm run dev # Start demo server at localhost:5692
npm run test:all # Run tests in all browsersLicense
0BSD (Zero-Clause BSD)
