Files
buuh/packages/html/morph.js
T
Bendik Aagaard LynghaugandClaude Fable 5 90cca611c9 feat(html): add @choojs/html — server renderer, raw(), morph
ESM ports with attribution: nanohtml 1.10.0 server tag (browserify/babel
transform branches removed — pure runtime), raw-server, and nanomorph 5.4.3
consolidated into one module. browser.js is a Phase 2 placeholder that
throws loudly. Covered by test/server.test.js on node:test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 15:59:26 +02:00

323 lines
9.6 KiB
JavaScript

// Ported from nanomorph 5.4.3 (MIT) — https://github.com/choojs/nanomorph
// Morph one DOM tree into another. Single-module consolidation of
// index.js + lib/morph.js + lib/events.js.
/* eslint-disable eqeqeq */
function equal (a, b, message) {
if (a != b) throw new Error(message)
}
function notEqual (a, b, message) {
if (a == b) throw new Error(message)
}
/* eslint-enable eqeqeq */
const ELEMENT_NODE = 1
const TEXT_NODE = 3
const COMMENT_NODE = 8
const events = [
// attribute events (can be set with attributes)
'onclick', 'ondblclick', 'onmousedown', 'onmouseup', 'onmouseover',
'onmousemove', 'onmouseout', 'onmouseenter', 'onmouseleave',
'ontouchcancel', 'ontouchend', 'ontouchmove', 'ontouchstart',
'ondragstart', 'ondrag', 'ondragenter', 'ondragleave', 'ondragover',
'ondrop', 'ondragend', 'onkeydown', 'onkeypress', 'onkeyup', 'onunload',
'onabort', 'onerror', 'onresize', 'onscroll', 'onselect', 'onchange',
'onsubmit', 'onreset', 'onfocus', 'onblur', 'oninput',
'onanimationend', 'onanimationiteration', 'onanimationstart',
// other common events
'oncontextmenu', 'onfocusin', 'onfocusout'
]
// Morph one tree into another tree
//
// no parent
// -> same: diff and walk children
// -> not same: replace and return
// old node doesn't exist
// -> insert new node
// new node doesn't exist
// -> delete old node
// nodes are not the same
// -> diff nodes and apply patch to old node
// nodes are the same
// -> walk all child nodes and append to old node
export default function nanomorph (oldTree, newTree, options) {
equal(typeof oldTree, 'object', 'nanomorph: oldTree should be an object')
equal(typeof newTree, 'object', 'nanomorph: newTree should be an object')
if (options && options.childrenOnly) {
updateChildren(newTree, oldTree)
return oldTree
}
notEqual(
newTree.nodeType,
11,
'nanomorph: newTree should have one root node (which is not a DocumentFragment)'
)
return walk(newTree, oldTree)
}
// Walk and morph a dom tree
function walk (newNode, oldNode) {
if (!oldNode) {
return newNode
} else if (!newNode) {
return null
} else if (newNode.isSameNode && newNode.isSameNode(oldNode)) {
return oldNode
} else if (newNode.tagName !== oldNode.tagName || getComponentId(newNode) !== getComponentId(oldNode)) {
return newNode
} else {
morph(newNode, oldNode)
updateChildren(newNode, oldNode)
return oldNode
}
}
function getComponentId (node) {
return node.dataset ? node.dataset.nanomorphComponentId : undefined
}
// Update the children of elements
function updateChildren (newNode, oldNode) {
let oldChild, newChild, morphed, oldMatch
// The offset is only ever increased, and used for [i - offset] in the loop
let offset = 0
for (let i = 0; ; i++) {
oldChild = oldNode.childNodes[i]
newChild = newNode.childNodes[i - offset]
// Both nodes are empty, do nothing
if (!oldChild && !newChild) {
break
// There is no new child, remove old
} else if (!newChild) {
oldNode.removeChild(oldChild)
i--
// There is no old child, add new
} else if (!oldChild) {
oldNode.appendChild(newChild)
offset++
// Both nodes are the same, morph
} else if (same(newChild, oldChild)) {
morphed = walk(newChild, oldChild)
if (morphed !== oldChild) {
oldNode.replaceChild(morphed, oldChild)
offset++
}
// Both nodes do not share an ID or a placeholder, try reorder
} else {
oldMatch = null
// Try and find a similar node somewhere in the tree
for (let j = i; j < oldNode.childNodes.length; j++) {
if (same(oldNode.childNodes[j], newChild)) {
oldMatch = oldNode.childNodes[j]
break
}
}
// If there was a node with the same ID or placeholder in the old list
if (oldMatch) {
morphed = walk(newChild, oldMatch)
if (morphed !== oldMatch) offset++
oldNode.insertBefore(morphed, oldChild)
// It's safe to morph two nodes in-place if neither has an ID
} else if (!newChild.id && !oldChild.id) {
morphed = walk(newChild, oldChild)
if (morphed !== oldChild) {
oldNode.replaceChild(morphed, oldChild)
offset++
}
// Insert the node at the index if we couldn't morph or find a matching node
} else {
oldNode.insertBefore(newChild, oldChild)
offset++
}
}
}
}
function same (a, b) {
if (a.id) return a.id === b.id
if (a.isSameNode) return a.isSameNode(b)
if (a.tagName !== b.tagName) return false
if (a.type === TEXT_NODE) return a.nodeValue === b.nodeValue
return false
}
// diff elements and apply the resulting patch to the old node
function morph (newNode, oldNode) {
const nodeType = newNode.nodeType
const nodeName = newNode.nodeName
if (nodeType === ELEMENT_NODE) {
copyAttrs(newNode, oldNode)
}
if (nodeType === TEXT_NODE || nodeType === COMMENT_NODE) {
if (oldNode.nodeValue !== newNode.nodeValue) {
oldNode.nodeValue = newNode.nodeValue
}
}
// Some DOM nodes are weird
// https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
if (nodeName === 'INPUT') updateInput(newNode, oldNode)
else if (nodeName === 'OPTION') updateOption(newNode, oldNode)
else if (nodeName === 'TEXTAREA') updateTextarea(newNode, oldNode)
copyEvents(newNode, oldNode)
}
function copyAttrs (newNode, oldNode) {
const oldAttrs = oldNode.attributes
const newAttrs = newNode.attributes
let attrNamespaceURI = null
let attrValue = null
let fromValue = null
let attrName = null
let attr = null
for (let i = newAttrs.length - 1; i >= 0; --i) {
attr = newAttrs[i]
attrName = attr.name
attrNamespaceURI = attr.namespaceURI
attrValue = attr.value
if (attrNamespaceURI) {
attrName = attr.localName || attrName
fromValue = oldNode.getAttributeNS(attrNamespaceURI, attrName)
if (fromValue !== attrValue) {
oldNode.setAttributeNS(attrNamespaceURI, attrName, attrValue)
}
} else {
if (!oldNode.hasAttribute(attrName)) {
oldNode.setAttribute(attrName, attrValue)
} else {
fromValue = oldNode.getAttribute(attrName)
if (fromValue !== attrValue) {
// apparently values are always cast to strings, ah well
if (attrValue === 'null' || attrValue === 'undefined') {
oldNode.removeAttribute(attrName)
} else {
oldNode.setAttribute(attrName, attrValue)
}
}
}
}
}
// Remove any extra attributes found on the original DOM element that
// weren't found on the target element.
for (let j = oldAttrs.length - 1; j >= 0; --j) {
attr = oldAttrs[j]
if (attr.specified !== false) {
attrName = attr.name
attrNamespaceURI = attr.namespaceURI
if (attrNamespaceURI) {
attrName = attr.localName || attrName
if (!newNode.hasAttributeNS(attrNamespaceURI, attrName)) {
oldNode.removeAttributeNS(attrNamespaceURI, attrName)
}
} else {
if (!newNode.hasAttributeNS(null, attrName)) {
oldNode.removeAttribute(attrName)
}
}
}
}
}
function copyEvents (newNode, oldNode) {
for (let i = 0; i < events.length; i++) {
const ev = events[i]
if (newNode[ev]) { // if new element has a whitelisted attribute
oldNode[ev] = newNode[ev] // update existing element
} else if (oldNode[ev]) { // if existing element has it and new one doesnt
oldNode[ev] = undefined // remove it from existing element
}
}
}
function updateOption (newNode, oldNode) {
updateAttribute(newNode, oldNode, 'selected')
}
// The "value" attribute is special for the <input> element since it sets the
// initial value. Changing the "value" attribute without changing the "value"
// property will have no effect since it is only used to the set the initial
// value. Similar for the "checked" attribute, and "disabled".
function updateInput (newNode, oldNode) {
const newValue = newNode.value
const oldValue = oldNode.value
updateAttribute(newNode, oldNode, 'checked')
updateAttribute(newNode, oldNode, 'disabled')
// The "indeterminate" property can not be set using an HTML attribute.
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox
if (newNode.indeterminate !== oldNode.indeterminate) {
oldNode.indeterminate = newNode.indeterminate
}
// Persist file value since file inputs can't be changed programatically
if (oldNode.type === 'file') return
if (newValue !== oldValue) {
oldNode.setAttribute('value', newValue)
oldNode.value = newValue
}
if (newValue === 'null') {
oldNode.value = ''
oldNode.removeAttribute('value')
}
if (!newNode.hasAttributeNS(null, 'value')) {
oldNode.removeAttribute('value')
} else if (oldNode.type === 'range') {
// this is so elements like slider move their UI thingy
oldNode.value = newValue
}
}
function updateTextarea (newNode, oldNode) {
const newValue = newNode.value
if (newValue !== oldNode.value) {
oldNode.value = newValue
}
if (oldNode.firstChild && oldNode.firstChild.nodeValue !== newValue) {
// Needed for IE. Apparently IE sets the placeholder as the
// node value and vise versa. This ignores an empty update.
if (newValue === '' && oldNode.firstChild.nodeValue === oldNode.placeholder) {
return
}
oldNode.firstChild.nodeValue = newValue
}
}
function updateAttribute (newNode, oldNode, name) {
if (newNode[name] !== oldNode[name]) {
oldNode[name] = newNode[name]
if (newNode[name]) {
oldNode.setAttribute(name, '')
} else {
oldNode.removeAttribute(name)
}
}
}