UNPKG

@alpinejs/morph

Version:

Diff and patch a block of HTML on a page with an HTML template

406 lines (293 loc) 11.3 kB
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) } }