From 3bce29e59e400b1217f5573fd9770260079fc2aa Mon Sep 17 00:00:00 2001 From: matheo Date: Tue, 7 Nov 2023 09:39:44 +0100 Subject: [PATCH 1/6] Migrate LiveComponent to Idiomorph --- .../assets/dist/live_controller.js | 1458 +++++++++-------- src/LiveComponent/assets/dist/morphdom.d.ts | 1 + src/LiveComponent/assets/package.json | 2 +- src/LiveComponent/assets/src/morphdom.ts | 42 +- .../assets/test/controller/render.test.ts | 2 +- yarn.lock | 10 +- 6 files changed, 764 insertions(+), 751 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 425b2e6a90b..7dc3231e910 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -425,764 +425,765 @@ class ValueStore { } } -var DOCUMENT_FRAGMENT_NODE = 11; +var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; + +//============================================================================= +// AMD insanity... i hate javascript so much +// +// IGNORE EVERYTHING FROM HERE UNTIL THE COMMENT SAYING 'AND NOW IT BEGINS..." +//============================================================================= +(function (root, factory) { + //@ts-ignore + { + // Browser globals + root.Idiomorph = root.Idiomorph || factory(); + } +}(typeof self !== 'undefined' ? self : commonjsGlobal, + function () { + return (function () { + + //============================================================================= + // AND NOW IT BEGINS... + //============================================================================= + let EMPTY_SET = new Set(); + + //============================================================================= + // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren + //============================================================================= + function morph(oldNode, newContent, config = {}) { + + if (oldNode instanceof Document) { + oldNode = oldNode.documentElement; + } -function morphAttrs(fromNode, toNode) { - var toNodeAttrs = toNode.attributes; - var attr; - var attrName; - var attrNamespaceURI; - var attrValue; - var fromValue; + if (typeof newContent === 'string') { + newContent = parseContent(newContent); + } - // document-fragments dont have attributes so lets not do anything - if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) { - return; - } + let normalizedContent = normalizeContent(newContent); + + let ctx = createMorphContext(oldNode, normalizedContent, config); + + return morphNormalizedContent(oldNode, normalizedContent, ctx); + } + + function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { + if (ctx.head.block) { + let oldHead = oldNode.querySelector('head'); + let newHead = normalizedNewContent.querySelector('head'); + if (oldHead && newHead) { + let promises = handleHeadElement(newHead, oldHead, ctx); + // when head promises resolve, call morph again, ignoring the head tag + Promise.all(promises).then(function () { + morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { + head: { + block: false, + ignore: true + } + })); + }); + return; + } + } + + if (ctx.morphStyle === "innerHTML") { - // 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; + // innerHTML, so we are only updating the children + morphChildren(normalizedNewContent, oldNode, ctx); + return oldNode.children; - if (attrNamespaceURI) { - attrName = attr.localName || attrName; - fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); + } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { + // otherwise find the best element match in the new content, morph that, and merge its siblings + // into either side of the best match + let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); - 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 + // stash the siblings that will need to be inserted on either side of the best match + let previousSibling = bestMatch?.previousSibling; + let nextSibling = bestMatch?.nextSibling; + + // morph it + let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); + + if (bestMatch) { + // if there was a best match, merge the siblings in too and return the + // whole bunch + return insertSiblings(previousSibling, morphedNode, nextSibling); + } else { + // otherwise nothing was added to the DOM + return [] + } + } else { + throw "Do not understand how to morph style " + ctx.morphStyle; } - fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); } - } else { - fromValue = fromNode.getAttribute(attrName); - if (fromValue !== attrValue) { - fromNode.setAttribute(attrName, attrValue); + + /** + * @param possibleActiveElement + * @param ctx + * @returns {boolean} + */ + function ignoreValueOfActiveElement(possibleActiveElement, ctx) { + return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement; + } + + /** + * @param oldNode root node to merge content into + * @param newContent new content to merge + * @param ctx the merge context + * @returns {Element} the element that ended up in the DOM + */ + function morphOldNodeTo(oldNode, newContent, ctx) { + if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return; + + oldNode.remove(); + ctx.callbacks.afterNodeRemoved(oldNode); + return null; + } else if (!isSoftMatch(oldNode, newContent)) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return; + if (ctx.callbacks.beforeNodeAdded(newContent) === false) return; + + oldNode.parentElement.replaceChild(newContent, oldNode); + ctx.callbacks.afterNodeAdded(newContent); + ctx.callbacks.afterNodeRemoved(oldNode); + return newContent; + } else { + if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return; + + if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { + handleHeadElement(newContent, oldNode, ctx); + } else { + syncNodeFrom(newContent, oldNode, ctx); + if (!ignoreValueOfActiveElement(oldNode, ctx)) { + morphChildren(newContent, oldNode, ctx); + } + } + ctx.callbacks.afterNodeMorphed(oldNode, newContent); + return oldNode; + } } - } - } - // Remove any extra attributes found on the original DOM element that - // weren't found on the target element. - var fromNodeAttrs = fromNode.attributes; + /** + * This is the core algorithm for matching up children. The idea is to use id sets to try to match up + * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but + * by using id sets, we are able to better match up with content deeper in the DOM. + * + * Basic algorithm is, for each node in the new content: + * + * - if we have reached the end of the old parent, append the new content + * - if the new content has an id set match with the current insertion point, morph + * - search for an id set match + * - if id set match found, morph + * - otherwise search for a "soft" match + * - if a soft match is found, morph + * - otherwise, prepend the new node before the current insertion point + * + * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved + * with the current node. See findIdSetMatch() and findSoftMatch() for details. + * + * @param {Element} newParent the parent element of the new content + * @param {Element } oldParent the old content that we are merging the new content into + * @param ctx the merge context + */ + function morphChildren(newParent, oldParent, ctx) { + + let nextNewChild = newParent.firstChild; + let insertionPoint = oldParent.firstChild; + let newChild; + + // run through all the new content + while (nextNewChild) { + + newChild = nextNewChild; + nextNewChild = newChild.nextSibling; + + // if we are at the end of the exiting parent's children, just append + if (insertionPoint == null) { + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; + + oldParent.appendChild(newChild); + ctx.callbacks.afterNodeAdded(newChild); + removeIdsFromConsideration(ctx, newChild); + continue; + } + + // if the current node has an id set match then morph + if (isIdSetMatch(newChild, insertionPoint, ctx)) { + morphOldNodeTo(insertionPoint, newChild, ctx); + insertionPoint = insertionPoint.nextSibling; + removeIdsFromConsideration(ctx, newChild); + continue; + } + + // otherwise search forward in the existing old children for an id set match + let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); - for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { - attr = fromNodeAttrs[d]; - attrName = attr.name; - attrNamespaceURI = attr.namespaceURI; + // if we found a potential match, remove the nodes until that point and morph + if (idSetMatch) { + insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); + morphOldNodeTo(idSetMatch, newChild, ctx); + removeIdsFromConsideration(ctx, newChild); + continue; + } + + // no id set match found, so scan forward for a soft match for the current node + let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); - if (attrNamespaceURI) { - attrName = attr.localName || attrName; + // if we found a soft match for the current node, morph + if (softMatch) { + insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); + morphOldNodeTo(softMatch, newChild, ctx); + removeIdsFromConsideration(ctx, newChild); + continue; + } - if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { - fromNode.removeAttributeNS(attrNamespaceURI, attrName); + // abandon all hope of morphing, just insert the new child before the insertion point + // and move on + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; + + oldParent.insertBefore(newChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newChild); + removeIdsFromConsideration(ctx, newChild); + } + + // remove any remaining old nodes that didn't match up with new content + while (insertionPoint !== null) { + + let tempNode = insertionPoint; + insertionPoint = insertionPoint.nextSibling; + removeNode(tempNode, ctx); + } } - } else { - if (!toNode.hasAttribute(attrName)) { - fromNode.removeAttribute(attrName); + + //============================================================================= + // Attribute Syncing Code + //============================================================================= + + /** + * syncs a given node with another node, copying over all attributes and + * inner element state from the 'from' node to the 'to' node + * + * @param {Element} from the element to copy attributes & state from + * @param {Element} to the element to copy attributes & state to + */ + function syncNodeFrom(from, to, ctx) { + let type = from.nodeType; + + // if is an element type, sync the attributes from the + // new node into the new node + if (type === 1 /* element type */) { + const fromAttributes = from.attributes; + const toAttributes = to.attributes; + for (const fromAttribute of fromAttributes) { + if (fromAttribute.name === 'value' && ignoreValueOfActiveElement(to, ctx)) { + continue; + } + if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { + to.setAttribute(fromAttribute.name, fromAttribute.value); + } + } + for (const toAttribute of toAttributes) { + if (!from.hasAttribute(toAttribute.name)) { + to.removeAttribute(toAttribute.name); + } + } + } + + // sync text nodes + if (type === 8 /* comment */ || type === 3 /* text */) { + if (to.nodeValue !== from.nodeValue) { + to.nodeValue = from.nodeValue; + } + } + + if (!ignoreValueOfActiveElement(to, ctx)) { + // sync input values + syncInputValue(from, to); + } } - } - } -} -var range; // Create a range object for efficently rendering strings to elements. -var NS_XHTML = 'http://www.w3.org/1999/xhtml'; + function syncAttribute(from, to, attributeName) { + if (from[attributeName] !== to[attributeName]) { + if (from[attributeName]) { + to.setAttribute(attributeName, from[attributeName]); + } else { + to.removeAttribute(attributeName); + } + } + } -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(); + // NB: many bothans died to bring us information: + // + // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js + // https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 + function syncInputValue(from, to) { + if (from instanceof HTMLInputElement && + to instanceof HTMLInputElement && + from.type !== 'file') { + + to.value = from.value || ''; + syncAttribute(from, to, 'value'); + + // sync boolean attributes + syncAttribute(from, to, 'checked'); + syncAttribute(from, to, 'disabled'); + } else if (from instanceof HTMLOptionElement) { + syncAttribute(from, to, 'selected'); + } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { + let fromValue = from.value; + let toValue = to.value; + if (fromValue !== toValue) { + to.value = fromValue; + } + if (to.firstChild && to.firstChild.nodeValue !== fromValue) { + to.firstChild.nodeValue = fromValue; + } + } + } -function createFragmentFromTemplate(str) { - var template = doc.createElement('template'); - template.innerHTML = str; - return template.content.childNodes[0]; -} + //============================================================================= + // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style + //============================================================================= + function handleHeadElement(newHeadTag, currentHead, ctx) { -function createFragmentFromRange(str) { - if (!range) { - range = doc.createRange(); - range.selectNode(doc.body); - } + let added = []; + let removed = []; + let preserved = []; + let nodesToAppend = []; - var fragment = range.createContextualFragment(str); - return fragment.childNodes[0]; -} + let headMergeStyle = ctx.head.style; -function createFragmentFromWrap(str) { - var fragment = doc.createElement('body'); - fragment.innerHTML = str; - return fragment.childNodes[0]; -} + // put all new head elements into a Map, by their outerHTML + let srcToNewHeadNodes = new Map(); + for (const newHeadChild of newHeadTag.children) { + srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); + } -/** - * 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 `Hi` which - // createContextualFragment doesn't support - //