diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2023-07-19 10:48:10 +0100 |
---|---|---|
committer | Hayleigh Thompson <me@hayleigh.dev> | 2023-07-19 10:48:10 +0100 |
commit | 42067167e278fef921a07addf974f2b278944222 (patch) | |
tree | e6b1213995af938eaf2ca0736112acc7d994144a | |
parent | 7b007a072f39cd69ff4ba16c5f766aa26fab47c9 (diff) | |
download | lustre-42067167e278fef921a07addf974f2b278944222.tar.gz lustre-42067167e278fef921a07addf974f2b278944222.zip |
:alembic: Maybe we can just roll our own DOM patching?
-rw-r--r-- | src/lustre.ffi.mjs | 8 | ||||
-rw-r--r-- | src/runtime.ffi.mjs | 988 |
2 files changed, 79 insertions, 917 deletions
diff --git a/src/lustre.ffi.mjs b/src/lustre.ffi.mjs index 3a6556e..b57026f 100644 --- a/src/lustre.ffi.mjs +++ b/src/lustre.ffi.mjs @@ -1,4 +1,4 @@ -import { morphdom } from "./runtime.ffi.mjs"; +import { morph } from "./runtime.ffi.mjs"; import { Ok, Error } from "./gleam.mjs"; import { map } from "./lustre/element.mjs"; @@ -14,6 +14,10 @@ export class App { #willUpdate = false; #didUpdate = false; + #init = null; + #update = null; + #view = null; + constructor(init, update, render) { this.#init = init; this.#update = update; @@ -51,7 +55,7 @@ export class App { const node = this.#view(this.#state); const vdom = map(node, (msg) => this.dispatch(msg)); - morphdom(this.#root.firstChild, vdom); + morph(this.#root, vdom); } #tick() { diff --git a/src/runtime.ffi.mjs b/src/runtime.ffi.mjs index c349a70..355c923 100644 --- a/src/runtime.ffi.mjs +++ b/src/runtime.ffi.mjs @@ -1,948 +1,106 @@ import { h, t } from "./lustre/element.mjs"; +import { fold, each } from "../gleam_stdlib/gleam/list.mjs"; -// This file is vendored from https://github.com/patrick-steele-idem/morphdom/ -// and is licensed under the MIT license. For a copy of the original license -// head on over to: -// -// https://github.com/patrick-steele-idem/morphdom/blob/master/LICENSE -// +const Element = h("").constructor; +const Text = t("").constructor; -var DOCUMENT_FRAGMENT_NODE = 11; +export function morph(prev, curr) { + if (curr instanceof Element) { + if (prev?.nodeType === 1 && prev.nodeName !== curr[0].toUpperCase()) { + return morphElement(prev, curr); + } else { + const el = document.createElement(curr[0]); -function morphAttrs(fromNode, toNode) { - var toNodeAttrs = toNode.attributes; - var attr; - var attrName; - var attrNamespaceURI; - var attrValue; - var fromValue; + each(curr[1], (attr) => { + const name = attr[0]; + const value = attr[1]; - // document-fragments dont have attributes so lets not do anything - if ( - toNode.nodeType === DOCUMENT_FRAGMENT_NODE || - fromNode.nodeType === DOCUMENT_FRAGMENT_NODE - ) { - return; - } + morphAttr(el, name, value); + }); - // update attributes on original DOM element - for (var i = toNodeAttrs.length - 1; i >= 0; i--) { - attr = toNodeAttrs[i]; - attrName = attr.name; - attrNamespaceURI = attr.namespaceURI; - attrValue = attr.value; + each(curr[2], (child) => { + el.appendChild(morph(null, child)); + }); - if (attrNamespaceURI) { - attrName = attr.localName || attrName; - fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); + if (prev) prev.replaceWith(el); - if (fromValue !== attrValue) { - if (attr.prefix === "xmlns") { - attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix - } - fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); - } - } else { - fromValue = fromNode.getAttribute(attrName); - - if (fromValue !== attrValue) { - fromNode.setAttribute(attrName, attrValue); - } + return el; } } - // Remove any extra attributes found on the original DOM element that - // weren't found on the target element. - var fromNodeAttrs = fromNode.attributes; - - for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { - attr = fromNodeAttrs[d]; - attrName = attr.name; - attrNamespaceURI = attr.namespaceURI; - - if (attrNamespaceURI) { - attrName = attr.localName || attrName; - - if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { - fromNode.removeAttributeNS(attrNamespaceURI, attrName); - } + if (curr instanceof Text) { + if (prev?.nodeType === 3) { + return morphText(prev, curr); } else { - if (!toNode.hasAttribute(attrName)) { - fromNode.removeAttribute(attrName); - } + const el = document.createTextNode(curr[0]); + if (prev) prev.replaceWith(el); + return el; } } -} - -var range; // Create a range object for efficently rendering strings to elements. -var NS_XHTML = "http://www.w3.org/1999/xhtml"; - -var doc = typeof document === "undefined" ? undefined : document; -var HAS_TEMPLATE_SUPPORT = !!doc && "content" in doc.createElement("template"); -var HAS_RANGE_SUPPORT = - !!doc && doc.createRange && "createContextualFragment" in doc.createRange(); - -function createFragmentFromTemplate(str) { - var template = doc.createElement("template"); - template.innerHTML = str; - return template.content.childNodes[0]; -} - -function createFragmentFromRange(str) { - if (!range) { - range = doc.createRange(); - range.selectNode(doc.body); - } - - var fragment = range.createContextualFragment(str); - return fragment.childNodes[0]; -} - -function createFragmentFromWrap(str) { - var fragment = doc.createElement("body"); - fragment.innerHTML = str; - return fragment.childNodes[0]; -} - -/** - * This is about the same - * var html = new DOMParser().parseFromString(str, 'text/html'); - * return html.body.firstChild; - * - * @method toElement - * @param {String} str - */ -function toElement(str) { - str = str.trim(); - if (HAS_TEMPLATE_SUPPORT) { - // avoid restrictions on content for things like `<tr><th>Hi</th></tr>` which - // createContextualFragment doesn't support - // <template> support not available in IE - return createFragmentFromTemplate(str); - } else if (HAS_RANGE_SUPPORT) { - return createFragmentFromRange(str); - } - return createFragmentFromWrap(str); + return null; } -/** - * Returns true if two node's names are the same. - * - * NOTE: We don't bother checking `namespaceURI` because you will never find two HTML elements with the same - * nodeName and different namespace URIs. - * - * @param {Element} a - * @param {Element} b The target element - * @return {boolean} - */ -function compareNodeNames(fromEl, toEl) { - var fromNodeName = fromEl.nodeName; - var toNodeName = toEl.nodeName; - var fromCodeStart, toCodeStart; +function morphElement(prev, curr) { + const prevAttrs = prev.attributes; + const currAttrs = fold(curr[1], new Map(), (acc, attr) => { + const name = attr[0]; + const value = attr[1]; - if (fromNodeName === toNodeName) { - return true; - } + acc.set(name, value); + return acc; + }); + const currChildren = curr[2].toArray(); - fromCodeStart = fromNodeName.charCodeAt(0); - toCodeStart = toNodeName.charCodeAt(0); + for (const { name, value: prevValue } of prevAttrs) { + if (!currAttrs.has(name)) prev.removeAttribute(name); + const value = currAttrs.get(name); - // If the target element is a virtual DOM node or SVG node then we may - // need to normalize the tag name before comparing. Normal HTML elements that are - // in the "http://www.w3.org/1999/xhtml" - // are converted to upper case - if (fromCodeStart <= 90 && toCodeStart >= 97) { - // from is upper and to is lower - return fromNodeName === toNodeName.toUpperCase(); - } else if (toCodeStart <= 90 && fromCodeStart >= 97) { - // to is upper and from is lower - return toNodeName === fromNodeName.toUpperCase(); - } else { - return false; + if (value !== prevValue) { + morphAttr(prev, name, value); + currAttrs.delete(name); + } } -} - -/** - * Create an element, optionally with a known namespace URI. - * - * @param {string} name the element name, e.g. 'div' or 'svg' - * @param {string} [namespaceURI] the element's namespace URI, i.e. the value of - * its `xmlns` attribute or its inferred namespace. - * - * @return {Element} - */ -function createElementNS(name, namespaceURI) { - return !namespaceURI || namespaceURI === NS_XHTML - ? doc.createElement(name) - : doc.createElementNS(namespaceURI, name); -} -/** - * Copies the children of one DOM element to another DOM element - */ -function moveChildren(fromEl, toEl) { - var curChild = fromEl.firstChild; - while (curChild) { - var nextChild = curChild.nextSibling; - toEl.appendChild(curChild); - curChild = nextChild; + for (const [name, value] of currAttrs) { + morphAttr(prev, name, value); } - return toEl; -} -function syncBooleanAttrProp(fromEl, toEl, name) { - if (fromEl[name] !== toEl[name]) { - fromEl[name] = toEl[name]; - if (fromEl[name]) { - fromEl.setAttribute(name, ""); + for (let child = prev.firstChild; child; child = child.nextSibling) { + if (currChildren.length) { + morph(child, currChildren.shift()); } else { - fromEl.removeAttribute(name); + prev.removeChild(child); } } -} - -var specialElHandlers = { - OPTION: function (fromEl, toEl) { - var parentNode = fromEl.parentNode; - if (parentNode) { - var parentName = parentNode.nodeName.toUpperCase(); - if (parentName === "OPTGROUP") { - parentNode = parentNode.parentNode; - parentName = parentNode && parentNode.nodeName.toUpperCase(); - } - if (parentName === "SELECT" && !parentNode.hasAttribute("multiple")) { - if (fromEl.hasAttribute("selected") && !toEl.selected) { - // Workaround for MS Edge bug where the 'selected' attribute can only be - // removed if set to a non-empty value: - // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12087679/ - fromEl.setAttribute("selected", "selected"); - fromEl.removeAttribute("selected"); - } - // We have to reset select element's selectedIndex to -1, otherwise setting - // fromEl.selected using the syncBooleanAttrProp below has no effect. - // The correct selectedIndex will be set in the SELECT special handler below. - parentNode.selectedIndex = -1; - } - } - syncBooleanAttrProp(fromEl, toEl, "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". - */ - INPUT: function (fromEl, toEl) { - syncBooleanAttrProp(fromEl, toEl, "checked"); - syncBooleanAttrProp(fromEl, toEl, "disabled"); - - if (fromEl.value !== toEl.value) { - fromEl.value = toEl.value; - } - - if (!toEl.hasAttribute("value")) { - fromEl.removeAttribute("value"); - } - }, - - TEXTAREA: function (fromEl, toEl) { - var newValue = toEl.value; - if (fromEl.value !== newValue) { - fromEl.value = newValue; - } - - var firstChild = fromEl.firstChild; - if (firstChild) { - // Needed for IE. Apparently IE sets the placeholder as the - // node value and vise versa. This ignores an empty update. - var oldValue = firstChild.nodeValue; - if ( - oldValue == newValue || - (!newValue && oldValue == fromEl.placeholder) - ) { - return; - } - - firstChild.nodeValue = newValue; - } - }, - SELECT: function (fromEl, toEl) { - if (!toEl.hasAttribute("multiple")) { - var selectedIndex = -1; - var i = 0; - // We have to loop through children of fromEl, not toEl since nodes can be moved - // from toEl to fromEl directly when morphing. - // At the time this special handler is invoked, all children have already been morphed - // and appended to / removed from fromEl, so using fromEl here is safe and correct. - var curChild = fromEl.firstChild; - var optgroup; - var nodeName; - while (curChild) { - nodeName = curChild.nodeName && curChild.nodeName.toUpperCase(); - if (nodeName === "OPTGROUP") { - optgroup = curChild; - curChild = optgroup.firstChild; - } else { - if (nodeName === "OPTION") { - if (curChild.hasAttribute("selected")) { - selectedIndex = i; - break; - } - i++; - } - curChild = curChild.nextSibling; - if (!curChild && optgroup) { - curChild = optgroup.nextSibling; - optgroup = null; - } - } - } - - fromEl.selectedIndex = selectedIndex; - } - }, -}; - -export const ELEMENT_NODE = 1; -export const DOCUMENT_FRAGMENT_NODE$1 = 11; -export const TEXT_NODE = 3; -export const COMMENT_NODE = 8; - -function noop() {} - -function defaultGetNodeKey(node) { - if (node) { - return (node.getAttribute && node.getAttribute("id")) || node.id; + for (const child of currChildren) { + prev.appendChild(morph(null, child)); } -} - -function morphdomFactory(morphAttrs) { - return function morphdom(fromNode, toNode, options) { - if (!options) { - options = {}; - } - - if (typeof toNode === "string") { - if ( - fromNode.nodeName === "#document" || - fromNode.nodeName === "HTML" || - fromNode.nodeName === "BODY" - ) { - var toNodeHtml = toNode; - toNode = doc.createElement("html"); - toNode.innerHTML = toNodeHtml; - } else { - toNode = toElement(toNode); - } - } else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE$1) { - toNode = toNode.firstElementChild; - } - - var getNodeKey = options.getNodeKey || defaultGetNodeKey; - var onBeforeNodeAdded = options.onBeforeNodeAdded || noop; - var onNodeAdded = options.onNodeAdded || noop; - var onBeforeElUpdated = options.onBeforeElUpdated || noop; - var onElUpdated = options.onElUpdated || noop; - var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop; - var onNodeDiscarded = options.onNodeDiscarded || noop; - var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop; - var skipFromChildren = options.skipFromChildren || noop; - var addChild = - options.addChild || - function (parent, child) { - return parent.appendChild(child); - }; - var childrenOnly = options.childrenOnly === true; - - // This object is used as a lookup to quickly find all keyed elements in the original DOM tree. - var fromNodesLookup = Object.create(null); - var keyedRemovalList = []; - - function addKeyedRemoval(key) { - keyedRemovalList.push(key); - } - - function walkDiscardedChildNodes(node, skipKeyedNodes) { - if (node.nodeType === ELEMENT_NODE) { - var curChild = node.firstChild; - while (curChild) { - var key = undefined; - - if (skipKeyedNodes && (key = getNodeKey(curChild))) { - // If we are skipping keyed nodes then we add the key - // to a list so that it can be handled at the very end. - addKeyedRemoval(key); - } else { - // Only report the node as discarded if it is not keyed. We do this because - // at the end we loop through all keyed elements that were unmatched - // and then discard them in one final pass. - onNodeDiscarded(curChild); - if (curChild.firstChild) { - walkDiscardedChildNodes(curChild, skipKeyedNodes); - } - } - - curChild = curChild.nextSibling; - } - } - } - - /** - * Removes a DOM node out of the original DOM - * - * @param {Node} node The node to remove - * @param {Node} parentNode The nodes parent - * @param {Boolean} skipKeyedNodes If true then elements with keys will be skipped and not discarded. - * @return {undefined} - */ - function removeNode(node, parentNode, skipKeyedNodes) { - if (onBeforeNodeDiscarded(node) === false) { - return; - } - - if (parentNode) { - parentNode.removeChild(node); - } - - onNodeDiscarded(node); - walkDiscardedChildNodes(node, skipKeyedNodes); - } - - function indexTree(node) { - if ( - node.nodeType === ELEMENT_NODE || - node.nodeType === DOCUMENT_FRAGMENT_NODE$1 - ) { - var curChild = node.firstChild; - while (curChild) { - var key = getNodeKey(curChild); - if (key) { - fromNodesLookup[key] = curChild; - } - - // Walk recursively - indexTree(curChild); - - curChild = curChild.nextSibling; - } - } - } - - indexTree(fromNode); - - function handleNodeAdded(el) { - onNodeAdded(el); - - var curChild = el.firstChild; - while (curChild) { - var nextSibling = curChild.nextSibling; - - var key = getNodeKey(curChild); - if (key) { - var unmatchedFromEl = fromNodesLookup[key]; - // if we find a duplicate #id node in cache, replace `el` with cache value - // and morph it to the child node. - if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) { - curChild.parentNode.replaceChild(unmatchedFromEl, curChild); - morphEl(unmatchedFromEl, curChild); - } else { - handleNodeAdded(curChild); - } - } else { - // recursively call for curChild and it's children to see if we find something in - // fromNodesLookup - handleNodeAdded(curChild); - } - - curChild = nextSibling; - } - } - - function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) { - // We have processed all of the "to nodes". If curFromNodeChild is - // non-null then we still have some from nodes left over that need - // to be removed - while (curFromNodeChild) { - var fromNextSibling = curFromNodeChild.nextSibling; - if ((curFromNodeKey = getNodeKey(curFromNodeChild))) { - // Since the node is keyed it might be matched up later so we defer - // the actual removal to later - addKeyedRemoval(curFromNodeKey); - } else { - // NOTE: we skip nested keyed nodes from being removed since there is - // still a chance they will be matched up later - removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */); - } - curFromNodeChild = fromNextSibling; - } - } - - function morphEl(fromEl, toEl, childrenOnly) { - var toElKey = getNodeKey(toEl); - if (toElKey) { - // If an element with an ID is being morphed then it will be in the final - // DOM so clear it out of the saved elements collection - delete fromNodesLookup[toElKey]; - } - - if (!childrenOnly) { - // optional - if (onBeforeElUpdated(fromEl, toEl) === false) { - return; - } - - // update attributes on original DOM element first - morphAttrs(fromEl, toEl); - // optional - onElUpdated(fromEl); - - if (onBeforeElChildrenUpdated(fromEl, toEl) === false) { - return; - } - } - - if (fromEl.nodeName !== "TEXTAREA") { - morphChildren(fromEl, toEl); - } else { - specialElHandlers.TEXTAREA(fromEl, toEl); - } - } - - function morphChildren(fromEl, toEl) { - var skipFrom = skipFromChildren(fromEl); - var curToNodeChild = toEl.firstChild; - var curFromNodeChild = fromEl.firstChild; - var curToNodeKey; - var curFromNodeKey; - - var fromNextSibling; - var toNextSibling; - var matchingFromEl; - - // walk the children - outer: while (curToNodeChild) { - toNextSibling = curToNodeChild.nextSibling; - curToNodeKey = getNodeKey(curToNodeChild); - - // walk the fromNode children all the way through - while (!skipFrom && curFromNodeChild) { - fromNextSibling = curFromNodeChild.nextSibling; - - if ( - curToNodeChild.isSameNode && - curToNodeChild.isSameNode(curFromNodeChild) - ) { - curToNodeChild = toNextSibling; - curFromNodeChild = fromNextSibling; - continue outer; - } - - curFromNodeKey = getNodeKey(curFromNodeChild); - - var curFromNodeType = curFromNodeChild.nodeType; - - // this means if the curFromNodeChild doesnt have a match with the curToNodeChild - var isCompatible = undefined; - - if (curFromNodeType === curToNodeChild.nodeType) { - if (curFromNodeType === ELEMENT_NODE) { - // Both nodes being compared are Element nodes - - if (curToNodeKey) { - // The target node has a key so we want to match it up with the correct element - // in the original DOM tree - if (curToNodeKey !== curFromNodeKey) { - // The current element in the original DOM tree does not have a matching key so - // let's check our lookup to see if there is a matching element in the original - // DOM tree - if ((matchingFromEl = fromNodesLookup[curToNodeKey])) { - if (fromNextSibling === matchingFromEl) { - // Special case for single element removals. To avoid removing the original - // DOM node out of the tree (since that can break CSS transitions, etc.), - // we will instead discard the current node and wait until the next - // iteration to properly match up the keyed target element with its matching - // element in the original tree - isCompatible = false; - } else { - // We found a matching keyed element somewhere in the original DOM tree. - // Let's move the original DOM node into the current position and morph - // it. - - // NOTE: We use insertBefore instead of replaceChild because we want to go through - // the `removeNode()` function for the node that is being discarded so that - // all lifecycle hooks are correctly invoked - fromEl.insertBefore(matchingFromEl, curFromNodeChild); - - // fromNextSibling = curFromNodeChild.nextSibling; - - if (curFromNodeKey) { - // Since the node is keyed it might be matched up later so we defer - // the actual removal to later - addKeyedRemoval(curFromNodeKey); - } else { - // NOTE: we skip nested keyed nodes from being removed since there is - // still a chance they will be matched up later - removeNode( - curFromNodeChild, - fromEl, - true /* skip keyed nodes */ - ); - } - - curFromNodeChild = matchingFromEl; - } - } else { - // The nodes are not compatible since the "to" node has a key and there - // is no matching keyed node in the source tree - isCompatible = false; - } - } - } else if (curFromNodeKey) { - // The original has a key - isCompatible = false; - } - - isCompatible = - isCompatible !== false && - compareNodeNames(curFromNodeChild, curToNodeChild); - if (isCompatible) { - // We found compatible DOM elements so transform - // the current "from" node to match the current - // target DOM node. - // MORPH - morphEl(curFromNodeChild, curToNodeChild); - } - } else if ( - curFromNodeType === TEXT_NODE || - curFromNodeType == COMMENT_NODE - ) { - // Both nodes being compared are Text or Comment nodes - isCompatible = true; - // Simply update nodeValue on the original node to - // change the text value - if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) { - curFromNodeChild.nodeValue = curToNodeChild.nodeValue; - } - } - } - - if (isCompatible) { - // Advance both the "to" child and the "from" child since we found a match - // Nothing else to do as we already recursively called morphChildren above - curToNodeChild = toNextSibling; - curFromNodeChild = fromNextSibling; - continue outer; - } - - // No compatible match so remove the old node from the DOM and continue trying to find a - // match in the original DOM. However, we only do this if the from node is not keyed - // since it is possible that a keyed node might match up with a node somewhere else in the - // target tree and we don't want to discard it just yet since it still might find a - // home in the final DOM tree. After everything is done we will remove any keyed nodes - // that didn't find a home - if (curFromNodeKey) { - // Since the node is keyed it might be matched up later so we defer - // the actual removal to later - addKeyedRemoval(curFromNodeKey); - } else { - // NOTE: we skip nested keyed nodes from being removed since there is - // still a chance they will be matched up later - removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */); - } - - curFromNodeChild = fromNextSibling; - } // END: while(curFromNodeChild) {} - - // If we got this far then we did not find a candidate match for - // our "to node" and we exhausted all of the children "from" - // nodes. Therefore, we will just append the current "to" node - // to the end - if ( - curToNodeKey && - (matchingFromEl = fromNodesLookup[curToNodeKey]) && - compareNodeNames(matchingFromEl, curToNodeChild) - ) { - // MORPH - if (!skipFrom) { - addChild(fromEl, matchingFromEl); - } - morphEl(matchingFromEl, curToNodeChild); - } else { - var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild); - if (onBeforeNodeAddedResult !== false) { - if (onBeforeNodeAddedResult) { - curToNodeChild = onBeforeNodeAddedResult; - } - - if (curToNodeChild.actualize) { - curToNodeChild = curToNodeChild.actualize( - fromEl.ownerDocument || doc - ); - } - addChild(fromEl, curToNodeChild); - handleNodeAdded(curToNodeChild); - } - } - - curToNodeChild = toNextSibling; - curFromNodeChild = fromNextSibling; - } - - cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey); - - var specialElHandler = specialElHandlers[fromEl.nodeName]; - if (specialElHandler) { - specialElHandler(fromEl, toEl); - } - } // END: morphChildren(...) - - var morphedNode = fromNode; - var morphedNodeType = morphedNode.nodeType; - var toNodeType = toNode.nodeType; - - if (!childrenOnly) { - // Handle the case where we are given two DOM nodes that are not - // compatible (e.g. <div> --> <span> or <div> --> TEXT) - if (morphedNodeType === ELEMENT_NODE) { - if (toNodeType === ELEMENT_NODE) { - if (!compareNodeNames(fromNode, toNode)) { - onNodeDiscarded(fromNode); - morphedNode = moveChildren( - fromNode, - createElementNS(toNode.nodeName, toNode.namespaceURI) - ); - } - } else { - // Going from an element node to a text node - morphedNode = toNode; - } - } else if ( - morphedNodeType === TEXT_NODE || - morphedNodeType === COMMENT_NODE - ) { - // Text or comment node - if (toNodeType === morphedNodeType) { - if (morphedNode.nodeValue !== toNode.nodeValue) { - morphedNode.nodeValue = toNode.nodeValue; - } - - return morphedNode; - } else { - // Text node to something else - morphedNode = toNode; - } - } - } - - if (morphedNode === toNode) { - // The "to node" was not compatible with the "from node" so we had to - // toss out the "from node" and use the "to node" - onNodeDiscarded(fromNode); - } else { - if (toNode.isSameNode && toNode.isSameNode(morphedNode)) { - return; - } - - morphEl(morphedNode, toNode, childrenOnly); - - // We now need to loop over any keyed nodes that might need to be - // removed. We only do the removal if we know that the keyed node - // never found a match. When a keyed node is matched up we remove - // it out of fromNodesLookup and we use fromNodesLookup to determine - // if a keyed node has been matched up or not - if (keyedRemovalList) { - for (var i = 0, len = keyedRemovalList.length; i < len; i++) { - var elToRemove = fromNodesLookup[keyedRemovalList[i]]; - if (elToRemove) { - removeNode(elToRemove, elToRemove.parentNode, false); - } - } - } - } - - if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) { - if (morphedNode.actualize) { - morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc); - } - // If we had to swap out the from node with a new node because the old - // node was not compatible with the target node then we need to - // replace the old DOM node in the original DOM tree. This is only - // possible if the original DOM node was part of a DOM tree which - // we know is the case if it has a parent node. - fromNode.parentNode.replaceChild(morphedNode, fromNode); - } - - return morphedNode; - }; + return prev; } -export const morphdom = morphdomFactory(morphAttrs); - -export default morphdom; - -// VDOM HACKS ------------------------------------------------------------------ - -// Whew this is some Naughty Stuff™. Our `Element` Gleam type is opaque so the -// class constructors are not exposed and we can't import them. We need to -// massage these classes into a shape that morphdom knows how to deal with, and -// because we can't get at the constructors directly we're going to be sneaky and -// get in by constructing some dummy elements and stealing their prototypes. -// -// Morphdom expects the following properties/methods to exist on a valid VDOM -// node: -// -// - firstChild; -// - nextSibling; -// - nodeType; -// - nodeName; -// - namespaceURI; -// - nodeValue; -// - attributes; -// - value; -// - selected; -// - disabled; -// - hasAttributeNS(namespaceURI, name); -// - actualize(document); -// -// See more here: https://github.com/patrick-steele-idem/morphdom/blob/master/docs/virtual-dom.md -// - -Object.defineProperties(h("").constructor.prototype, { - firstChild: { - get() { - // If this is the first time `firstChild` is being accessed, we need to - // create the children array first. - if (!this.children) this.children = this[2].toArray(); - - const child = this.children[0]; - - if (child) { - child.parentElement = this; - child.index = 0; - } - - return child; - }, - }, - - nextSibling: { - get() { - const sibling = this.parentElement?.children[this.index + 1]; - - if (sibling) { - sibling.parentElement = this.parentElement; - sibling.index = this.index + 1; - } - - return sibling; - }, - }, - - nodeType: { - value: ELEMENT_NODE, - }, - - nodeName: { - get() { - return this[0].toUpperCase(); - }, - }, - - namespaceURI: { - value: undefined, - }, - - nodeValue: { - value: null, - }, - - attributes: { - get() { - if (!this._attributes) { - this._attributes = Object.fromEntries(this[1].toArray()); - } - - return this._attributes; - }, - }, - - value: { - get() { - return this.attributes.value; - }, - }, - - selected: { - get() { - return !!this.attributes.selected; - }, - }, - - disabled: { - get() { - return !!this.attributes.disabled; - }, - }, - - hasAttributeNS: { - value: function (_, name) { - return name in this.attributes; - }, - }, - - actualize: { - value: function (document) { - const el = document.createElement(this[0]); - - for (const key in this.attributes) { - el[key] = this.attributes[key]; - } - - for (let child = this.firstChild; !!child; child = child.nextSibling) { - el.appendChild(child.actualize(document)); - } - - return el; - }, - }, -}); - -Object.defineProperties(t("").constructor.prototype, { - nextSibling: { - get() { - const sibling = this.parentElement?.children[this.index + 1]; - - if (sibling) { - sibling.parentElement = this.parentElement; - sibling.index = this.index + 1; - } - - return sibling; - }, - }, - - nodeType: { - get() { - return TEXT_NODE; - }, - }, - - nodeName: { - value: "#text", - }, - - nodeValue: { - get() { - return this[0]; - }, - }, +function morphText(prev, curr) { + if (prev.nodeValue !== curr[0]) prev.nodeValue = curr[0]; + return prev; +} - actualize: { - value: function (document) { - return document.createTextNode(this[0]); - }, - }, -}); +function morphAttr(el, name, value) { + switch (typeof value) { + case "string": + el.setAttribute(name, value); + break; + + // Boolean attributes work a bit differently in HTML. Their presence always + // implies true: to set an attribute to false you need to remove it entirely. + case "boolean": + value ? el.setAttribute(name, name) : el.removeAttribute(name); + break; + + // For properties, we're leaning on reference equality to avoid unnecessary + // updates. + default: + if (el[name] !== value) el[name] = value; + } +} |