@alpinejs/morph
Version:
Diff and patch a block of HTML on a page with an HTML template
406 lines (293 loc) • 11.3 kB
JavaScript
import { dom, createElement, textOrComment} from './dom.js'
let resolveStep = () => {}
let logger = () => {}
export async function morph(from, toHtml, options) {
// We're defining these globals and methods inside this function (instead of outside)
// because it's an async function and if run twice, they would overwrite
// each other.
let fromEl
let toEl
let key
,lookahead
,updating
,updated
,removing
,removed
,adding
,added
,debug
function breakpoint(message) {
if (! debug) return
logger((message || '').replace('\n', '\\n'), fromEl, toEl)
return new Promise(resolve => resolveStep = () => resolve())
}
function assignOptions(options = {}) {
let defaultGetKey = el => el.getAttribute('key')
let noop = () => {}
updating = options.updating || noop
updated = options.updated || noop
removing = options.removing || noop
removed = options.removed || noop
adding = options.adding || noop
added = options.added || noop
key = options.key || defaultGetKey
lookahead = options.lookahead || false
debug = options.debug || false
}
async function patch(from, to) {
// This is a time saver, however, it won't catch differences in nested <template> tags.
// I'm leaving this here as I believe it's an important speed improvement, I just
// don't see a way to enable it currently:
//
// if (from.isEqualNode(to)) return
if (differentElementNamesTypesOrKeys(from, to)) {
let result = patchElement(from, to)
await breakpoint('Swap elements')
return result
}
let updateChildrenOnly = false
if (shouldSkip(updating, from, to, () => updateChildrenOnly = true)) return
window.Alpine && initializeAlpineOnTo(from, to, () => updateChildrenOnly = true)
if (textOrComment(to)) {
await patchNodeValue(from, to)
updated(from, to)
return
}
if (! updateChildrenOnly) {
await patchAttributes(from, to)
}
updated(from, to)
await patchChildren(from, to)
}
function differentElementNamesTypesOrKeys(from, to) {
return from.nodeType != to.nodeType
|| from.nodeName != to.nodeName
|| getKey(from) != getKey(to)
}
function patchElement(from, to) {
if (shouldSkip(removing, from)) return
let toCloned = to.cloneNode(true)
if (shouldSkip(adding, toCloned)) return
dom(from).replace(toCloned)
removed(from)
added(toCloned)
}
async function patchNodeValue(from, to) {
let value = to.nodeValue
if (from.nodeValue !== value) {
from.nodeValue = value
await breakpoint('Change text node to: ' + value)
}
}
async function patchAttributes(from, to) {
if (from._x_isShown && ! to._x_isShown) {
return
}
if (! from._x_isShown && to._x_isShown) {
return
}
let domAttributes = Array.from(from.attributes)
let toAttributes = Array.from(to.attributes)
for (let i = domAttributes.length - 1; i >= 0; i--) {
let name = domAttributes[i].name;
if (! to.hasAttribute(name)) {
from.removeAttribute(name)
await breakpoint('Remove attribute')
}
}
for (let i = toAttributes.length - 1; i >= 0; i--) {
let name = toAttributes[i].name
let value = toAttributes[i].value
if (from.getAttribute(name) !== value) {
from.setAttribute(name, value)
await breakpoint(`Set [${name}] attribute to: "${value}"`)
}
}
}
async function patchChildren(from, to) {
let domChildren = from.childNodes
let toChildren = to.childNodes
let toKeyToNodeMap = keyToMap(toChildren)
let domKeyDomNodeMap = keyToMap(domChildren)
let currentTo = dom(to).nodes().first()
let currentFrom = dom(from).nodes().first()
let domKeyHoldovers = {}
let isInsideWall = false
while (currentTo) {
// If "<!-- end -->"
if (
currentTo.nodeType === 8
&& currentTo.textContent === ' end '
) {
isInsideWall = false
currentTo = dom(currentTo).nodes().next()
currentFrom = dom(currentFrom).nodes().next()
continue
}
if (insideWall)
if (isInsideWall) {
console.log(currentFrom, currentTo)
}
let toKey = getKey(currentTo)
let domKey = getKey(currentFrom)
// Add new elements
if (! currentFrom) {
if (toKey && domKeyHoldovers[toKey]) {
let holdover = domKeyHoldovers[toKey]
dom(from).append(holdover)
currentFrom = holdover
await breakpoint('Add element (from key)')
} else {
let added = addNodeTo(currentTo, from) || {}
await breakpoint('Add element: ' + (added.outerHTML || added.nodeValue))
currentTo = dom(currentTo).nodes().next()
continue
}
}
// If "<!-- if -->"
if (
currentTo.nodeType === 8
&& currentTo.textContent === ' if '
&& currentFrom.nodeType === 8
&& currentFrom.textContent === ' if '
) {
isInsideWall = true
currentTo = dom(currentTo).nodes().next()
currentFrom = dom(currentFrom).nodes().next()
continue
}
if (lookahead) {
let nextToElementSibling = dom(currentTo).next()
let found = false
while (!found && nextToElementSibling) {
if (currentFrom.isEqualNode(nextToElementSibling)) {
found = true
currentFrom = addNodeBefore(currentTo, currentFrom)
domKey = getKey(currentFrom)
await breakpoint('Move element (lookahead)')
}
nextToElementSibling = dom(nextToElementSibling).next()
}
}
if (toKey !== domKey) {
if (! toKey && domKey) {
domKeyHoldovers[domKey] = currentFrom
currentFrom = addNodeBefore(currentTo, currentFrom)
domKeyHoldovers[domKey].remove()
currentFrom = dom(currentFrom).nodes().next()
currentTo = dom(currentTo).nodes().next()
await breakpoint('No "to" key')
continue
}
if (toKey && ! domKey) {
if (domKeyDomNodeMap[toKey]) {
currentFrom = dom(currentFrom).replace(domKeyDomNodeMap[toKey])
await breakpoint('No "from" key')
}
}
if (toKey && domKey) {
domKeyHoldovers[domKey] = currentFrom
let domKeyNode = domKeyDomNodeMap[toKey]
if (domKeyNode) {
currentFrom = dom(currentFrom).replace(domKeyNode)
await breakpoint('Move "from" key')
} else {
domKeyHoldovers[domKey] = currentFrom
currentFrom = addNodeBefore(currentTo, currentFrom)
domKeyHoldovers[domKey].remove()
currentFrom = dom(currentFrom).next()
currentTo = dom(currentTo).next()
await breakpoint('Swap elements with keys')
continue
}
}
}
// Get next from sibling before patching in case the node is replaced
let currentFromNext = currentFrom && dom(currentFrom).nodes().next()
// Patch elements
await patch(currentFrom, currentTo)
currentTo = currentTo && dom(currentTo).nodes().next()
currentFrom = currentFromNext
}
// Cleanup extra forms.
let removals = []
// We need to collect the "removals" first before actually
// removing them so we don't mess with the order of things.
while (currentFrom) {
if(! shouldSkip(removing, currentFrom)) removals.push(currentFrom)
currentFrom = dom(currentFrom).nodes().next()
}
// Now we can do the actual removals.
while (removals.length) {
let domForRemoval = removals.shift()
domForRemoval.remove()
await breakpoint('remove el')
removed(domForRemoval)
}
}
function getKey(el) {
return el && el.nodeType === 1 && key(el)
}
function keyToMap(els) {
let map = {}
els.forEach(el => {
let theKey = getKey(el)
if (theKey) {
map[theKey] = el
}
})
return map
}
function addNodeTo(node, parent) {
if(! shouldSkip(adding, node)) {
let clone = node.cloneNode(true)
dom(parent).append(clone)
added(clone)
return clone
}
return null;
}
function addNodeBefore(node, beforeMe) {
if(! shouldSkip(adding, node)) {
let clone = node.cloneNode(true)
dom(beforeMe).before(clone)
added(clone)
return clone
}
return beforeMe
}
// Finally we morph the element
assignOptions(options)
fromEl = from
toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
// If there is no x-data on the element we're morphing,
// let's seed it with the outer Alpine scope on the page.
if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) {
toEl._x_dataStack = window.Alpine.closestDataStack(from)
toEl._x_dataStack && window.Alpine.clone(from, toEl)
}
await breakpoint()
await patch(from, toEl)
// Release these for the garbage collector.
fromEl = undefined
toEl = undefined
return from
}
morph.step = () => resolveStep()
morph.log = (theLogger) => {
logger = theLogger
}
function shouldSkip(hook, ...args) {
let skip = false
hook(...args, () => skip = true)
return skip
}
function initializeAlpineOnTo(from, to, childrenOnly) {
if (from.nodeType !== 1) return
// If the element we are updating is an Alpine component...
if (from._x_dataStack) {
// Then temporarily clone it (with it's data) to the "to" element.
// This should simulate backend Livewire being aware of Alpine changes.
window.Alpine.clone(from, to)
}
}