From b333d4caef5a23f5c45f045610f76477362050df Mon Sep 17 00:00:00 2001 From: Gianluca Guarini Date: Sat, 4 May 2019 21:28:02 +0200 Subject: [PATCH] updated: improve the hydration fixing #1 --- hydrate.js | 668 +++----------------------------- package-lock.json | 78 +++- package.json | 4 +- src/index.js | 77 ++-- test/components/with-loops.riot | 4 +- test/index.js | 25 +- 6 files changed, 191 insertions(+), 665 deletions(-) diff --git a/hydrate.js b/hydrate.js index 44dddd6..a2368db 100644 --- a/hydrate.js +++ b/hydrate.js @@ -4,150 +4,6 @@ (global = global || self, global.hydrate = factory(global.riot)); }(this, function (riot) { 'use strict'; - function morphAttrs(fromNode, toNode) { - var attrs = toNode.attributes; - var i; - var attr; - var attrName; - var attrNamespaceURI; - var attrValue; - var fromValue; - - // update attributes on original DOM element - for (i = attrs.length - 1; i >= 0; --i) { - attr = attrs[i]; - attrName = attr.name; - attrNamespaceURI = attr.namespaceURI; - attrValue = attr.value; - - if (attrNamespaceURI) { - attrName = attr.localName || attrName; - fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); - - if (fromValue !== attrValue) { - fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); - } - } else { - fromValue = fromNode.getAttribute(attrName); - - if (fromValue !== attrValue) { - fromNode.setAttribute(attrName, attrValue); - } - } - } - - // Remove any extra attributes found on the original DOM element that - // weren't found on the target element. - attrs = fromNode.attributes; - - for (i = attrs.length - 1; i >= 0; --i) { - attr = attrs[i]; - if (attr.specified !== false) { - attrName = attr.name; - attrNamespaceURI = attr.namespaceURI; - - if (attrNamespaceURI) { - attrName = attr.localName || attrName; - - if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { - fromNode.removeAttributeNS(attrNamespaceURI, attrName); - } - } else { - if (!toNode.hasAttribute(attrName)) { - fromNode.removeAttribute(attrName); - } - } - } - } - } - - 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; - - /** - * 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) { - if (!range && doc.createRange) { - range = doc.createRange(); - range.selectNode(doc.body); - } - - var fragment; - if (range && range.createContextualFragment) { - fragment = range.createContextualFragment(str); - } else { - fragment = doc.createElement('body'); - fragment.innerHTML = str; - } - return fragment.childNodes[0]; - } - - /** - * 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; - - if (fromNodeName === toNodeName) { - return true; - } - - if (toEl.actualize && - fromNodeName.charCodeAt(0) < 91 && /* from tag name is upper case */ - toNodeName.charCodeAt(0) > 90 /* target tag name is lower case */) { - // If the target element is a virtual DOM 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 - return fromNodeName === toNodeName.toUpperCase(); - } else { - return false; - } - } - - /** - * 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; - } - return toEl; - } - function syncBooleanAttrProp(fromEl, toEl, name) { if (fromEl[name] !== toEl[name]) { fromEl[name] = toEl[name]; @@ -259,498 +115,69 @@ } }; - var ELEMENT_NODE = 1; - var DOCUMENT_FRAGMENT_NODE = 11; - var TEXT_NODE = 3; - var COMMENT_NODE = 8; - - function noop() {} - - function defaultGetNodeKey(node) { - return node.id; + /** + * Create a DOM tree walker + * @param {HTMLElement} node - root node where we will start the crawling + * @returns {TreeWalker} the TreeWalker object + */ + function createWalker(node) { + return document.createTreeWalker( + node, + NodeFilter.SHOW_ELEMENT, + { acceptNode: () => NodeFilter.FILTER_ACCEPT }, + false + ) } - function morphdomFactory(morphAttrs) { - - return function morphdom(fromNode, toNode, options) { - if (!options) { - options = {}; - } - - if (typeof toNode === 'string') { - if (fromNode.nodeName === '#document' || fromNode.nodeName === 'HTML') { - var toNodeHtml = toNode; - toNode = doc.createElement('html'); - toNode.innerHTML = toNodeHtml; - } else { - toNode = toElement(toNode); - } - } - - 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 childrenOnly = options.childrenOnly === true; - - // This object is used as a lookup to quickly find all keyed elements in the original DOM tree. - var fromNodesLookup = {}; - var keyedRemovalList; - - function addKeyedRemoval(key) { - if (keyedRemovalList) { - keyedRemovalList.push(key); - } else { - keyedRemovalList = [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); - } - - // // TreeWalker implementation is no faster, but keeping this around in case this changes in the future - // function indexTree(root) { - // var treeWalker = document.createTreeWalker( - // root, - // NodeFilter.SHOW_ELEMENT); - // - // var el; - // while((el = treeWalker.nextNode())) { - // var key = getNodeKey(el); - // if (key) { - // fromNodesLookup[key] = el; - // } - // } - // } - - // // NodeIterator implementation is no faster, but keeping this around in case this changes in the future - // - // function indexTree(node) { - // var nodeIterator = document.createNodeIterator(node, NodeFilter.SHOW_ELEMENT); - // var el; - // while((el = nodeIterator.nextNode())) { - // var key = getNodeKey(el); - // if (key) { - // fromNodesLookup[key] = el; - // } - // } - // } - - function indexTree(node) { - if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE) { - 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 (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) { - curChild.parentNode.replaceChild(unmatchedFromEl, curChild); - morphEl(unmatchedFromEl, curChild); - } - } - - 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 (toNode.isSameNode && toNode.isSameNode(fromNode)) { - return; - } - - 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 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 (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)) { - fromEl.appendChild(matchingFromEl); - // MORPH - morphEl(matchingFromEl, curToNodeChild); - } else { - var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild); - if (onBeforeNodeAddedResult !== false) { - if (onBeforeNodeAddedResult) { - curToNodeChild = onBeforeNodeAddedResult; - } - - if (curToNodeChild.actualize) { - curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc); - } - fromEl.appendChild(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.
--> or
--> 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 { - 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 { + targetNode.focus(); + }); + } - return morphedNode; - }; + if (specialHandler) { + specialHandler(targetNode, sourceNode); + } } - var morphdom = morphdomFactory(morphAttrs); - /** * Morph the existing DOM node with the new created one - * @param {HTMLElement} clone - clone of the original DOM node to replace - * @param {HTMLElement} element - node to replace + * @param {HTMLElement} sourceElement - the root node already pre-rendered in the DOM + * @param {HTMLElement} targetElement - the root node of the Riot.js component mounted in runtime * @returns {undefined} void function */ - function morph(clone, element) { - const { activeElement } = document; - - morphdom(clone, element, { - onBeforeElUpdated: function(fromEl, toEl) { - if (toEl === activeElement) { - fromEl.isActive = true; - } - - return true - }, - onElUpdated(node) { - if (node.isActive) { - delete node.isActive; - - requestAnimationFrame(() => { - node.focus(); - }); - } + function morph(sourceElement, targetElement) { + const sourceWalker = createWalker(sourceElement); + const targetWalker = createWalker(targetElement); + // recursive function to walk source element tree + const walk = fn => sourceWalker.nextNode() && targetWalker.nextNode() && fn() && walk(fn); + + walk(() => { + const { currentNode } = sourceWalker; + const targetNode = targetWalker.currentNode; + + if (currentNode.tagName === targetNode.tagName) { + sync(currentNode, targetNode); } + + return true }); } /** * Create a custom Riot.js mounting function to hydrate an existing SSR DOM node * @param {RiotComponentShell} componentAPI - component shell - * @returns {function} function similar to the riot.component + * @returns {Function} function similar to the riot.component */ function hydrate(componentAPI) { const mountComponent = riot.component(componentAPI); @@ -763,7 +190,8 @@ instance.onBeforeHydrate(instance.props, instance.state); // morph the nodes - morph(clone, element); + morph(element, clone); + // swap the html element.parentNode.replaceChild(clone, element); diff --git a/package-lock.json b/package-lock.json index 374aaa1..b806942 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@riotjs/hydrate", - "version": "0.0.0", + "version": "1.0.0-beta.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1251,6 +1251,15 @@ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, + "create-eslint-index": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/create-eslint-index/-/create-eslint-index-1.0.0.tgz", + "integrity": "sha1-2VQ3LYbVeS/NZ+nyt5GxqxYkEbs=", + "dev": true, + "requires": { + "lodash.get": "^4.3.0" + } + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -1529,11 +1538,36 @@ "text-table": "^0.2.0" } }, + "eslint-ast-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-ast-utils/-/eslint-ast-utils-1.1.0.tgz", + "integrity": "sha512-otzzTim2/1+lVrlH19EfQQJEhVJSu0zOb9ygb3iapN6UlyaDtyRq4b5U1FuW0v1lRa9Fp/GJyHkSwm6NqABgCA==", + "dev": true, + "requires": { + "lodash.get": "^4.4.2", + "lodash.zip": "^4.2.0" + } + }, "eslint-config-riot": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-riot/-/eslint-config-riot-1.0.0.tgz", - "integrity": "sha1-+9ZThpgLMPvNDhMF1MP7hhTvIRk=", - "dev": true + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-riot/-/eslint-config-riot-2.0.0.tgz", + "integrity": "sha512-l8a3HNTfT5o6XOj+eGpAkRP/VQsbND4jWD97YPizy2CXSTWX8nza28cmDItzrlv89sEUegPCRAYnexzGF+ASAw==", + "dev": true, + "requires": { + "eslint-plugin-fp": "^2.3.0" + } + }, + "eslint-plugin-fp": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-fp/-/eslint-plugin-fp-2.3.0.tgz", + "integrity": "sha1-N20qEIcQ6YGYC9w4deO5kg2gSJw=", + "dev": true, + "requires": { + "create-eslint-index": "^1.0.0", + "eslint-ast-utils": "^1.0.0", + "lodash": "^4.13.1", + "req-all": "^0.1.0" + } }, "eslint-scope": { "version": "4.0.3", @@ -2225,12 +2259,24 @@ "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", "dev": true }, + "lodash.zip": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", + "integrity": "sha1-7GZi5IlkCO1KtsVCo5kLcswIACA=", + "dev": true + }, "log-symbols": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", @@ -2820,6 +2866,12 @@ } } }, + "req-all": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/req-all/-/req-all-0.1.0.tgz", + "integrity": "sha1-EwBR4qzligLqy/ydRIV3pzapJzo=", + "dev": true + }, "request": { "version": "2.88.0", "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", @@ -2933,14 +2985,22 @@ } }, "rollup": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.10.1.tgz", - "integrity": "sha512-pW353tmBE7QP622ITkGxtqF0d5gSRCVPD9xqM+fcPjudeZfoXMFW2sCzsTe2TU/zU1xamIjiS9xuFCPVT9fESw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.11.2.tgz", + "integrity": "sha512-H5sS7GZ/Rn0t119Et8mw0QXtg5HTOI/1FL57EKHk5oduRmGaraOf3KcEt6j+dXJ9tXxWQkG+/FBjPS4dzxo6EA==", "dev": true, "requires": { "@types/estree": "0.0.39", - "@types/node": "^11.13.5", + "@types/node": "^11.13.9", "acorn": "^6.1.1" + }, + "dependencies": { + "@types/node": { + "version": "11.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-11.13.10.tgz", + "integrity": "sha512-leUNzbFTMX94TWaIKz8N15Chu55F9QSH+INKayQr5xpkasBQBRF3qQXfo3/dOnMU/dEIit+Y/SU8HyOjq++GwA==", + "dev": true + } } }, "rollup-plugin-node-resolve": { diff --git a/package.json b/package.json index 970feb7..3a2162d 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,12 @@ "@riotjs/ssr": "^4.0.0-rc.2", "chai": "^4.2.0", "eslint": "^5.16.0", - "eslint-config-riot": "^1.0.0", + "eslint-config-riot": "^2.0.0", "esm": "^3.2.22", "jsdom": "15.0.0", "jsdom-global": "3.0.2", "mocha": "^6.1.4", - "rollup": "^1.10.1", + "rollup": "^1.11.2", "rollup-plugin-node-resolve": "^4.2.3", "sinon": "^7.3.2", "sinon-chai": "^3.3.0" diff --git a/src/index.js b/src/index.js index 114ad26..ae9625c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,39 +1,69 @@ import { component } from 'riot' -import morphdom from 'morphdom' +import specialElHandlers from 'morphdom/src/specialElHandlers' /** - * Morph the existing DOM node with the new created one - * @param {HTMLElement} clone - clone of the original DOM node to replace - * @param {HTMLElement} element - node to replace + * Create a DOM tree walker + * @param {HTMLElement} node - root node where we will start the crawling + * @returns {TreeWalker} the TreeWalker object + */ +function createWalker(node) { + return document.createTreeWalker( + node, + NodeFilter.SHOW_ELEMENT, + { acceptNode: () => NodeFilter.FILTER_ACCEPT }, + false + ) +} + +/** + * Sync a source node with the one rendered in runtime + * @param {HTMLElement} sourceNode - node pre-rendered in the DOM + * @param {HTMLElement} targetNode - node generated in runtime * @returns {undefined} void function */ -function morph(clone, element) { +function sync(sourceNode, targetNode) { const { activeElement } = document + const specialHandler = specialElHandlers[sourceNode.tagName] + + if (sourceNode === activeElement) { + window.requestAnimationFrame(() => { + targetNode.focus() + }) + } + + if (specialHandler) { + specialHandler(targetNode, sourceNode) + } +} - morphdom(clone, element, { - onBeforeElUpdated: function(fromEl, toEl) { - if (toEl === activeElement) { - fromEl.isActive = true - } - - return true - }, - onElUpdated(node) { - if (node.isActive) { - delete node.isActive - - requestAnimationFrame(() => { - node.focus() - }) - } +/** + * Morph the existing DOM node with the new created one + * @param {HTMLElement} sourceElement - the root node already pre-rendered in the DOM + * @param {HTMLElement} targetElement - the root node of the Riot.js component mounted in runtime + * @returns {undefined} void function + */ +function morph(sourceElement, targetElement) { + const sourceWalker = createWalker(sourceElement) + const targetWalker = createWalker(targetElement) + // recursive function to walk source element tree + const walk = fn => sourceWalker.nextNode() && targetWalker.nextNode() && fn() && walk(fn) + + walk(() => { + const { currentNode } = sourceWalker + const targetNode = targetWalker.currentNode + + if (currentNode.tagName === targetNode.tagName) { + sync(currentNode, targetNode) } + + return true }) } /** * Create a custom Riot.js mounting function to hydrate an existing SSR DOM node * @param {RiotComponentShell} componentAPI - component shell - * @returns {function} function similar to the riot.component + * @returns {Function} function similar to the riot.component */ export default function hydrate(componentAPI) { const mountComponent = component(componentAPI) @@ -46,7 +76,8 @@ export default function hydrate(componentAPI) { instance.onBeforeHydrate(instance.props, instance.state) // morph the nodes - morph(clone, element) + morph(element, clone) + // swap the html element.parentNode.replaceChild(clone, element) diff --git a/test/components/with-loops.riot b/test/components/with-loops.riot index d60ca8b..53dac53 100644 --- a/test/components/with-loops.riot +++ b/test/components/with-loops.riot @@ -1,4 +1,4 @@ - +

With Loops

{item} @@ -23,4 +23,4 @@ } } - \ No newline at end of file + \ No newline at end of file diff --git a/test/index.js b/test/index.js index f6e774b..f0ee488 100644 --- a/test/index.js +++ b/test/index.js @@ -1,27 +1,36 @@ +import {expect, use} from 'chai' import JSDOMGlobal from 'jsdom-global' +import hydrate from '../' import register from '@riotjs/ssr/register' -import {expect, use} from 'chai' import sinonChai from 'sinon-chai' import {spy} from 'sinon' -import hydrate from '../' describe('@riotjs/hydrate', () => { before(() => { - JSDOMGlobal() + JSDOMGlobal(false, { pretendToBeVisual: true }) use(sinonChai) register() }) - it('it replaces the DOM nodes properly', () => { + it('it replaces the DOM nodes properly', (done) => { const MyComponent = require('./components/my-component.riot').default const root = document.createElement('div') root.innerHTML = '

goodbye

' document.body.appendChild(root) + root.querySelector('input').focus() + + expect(document.activeElement === root.querySelector('input')).to.be.ok + const instance = hydrate(MyComponent)(root) - expect(instance.$('p').innerHTML).to.be.equal('goodbye') + expect(instance.$('p').innerHTML).to.be.equal('hello') expect(instance.$('input').value).to.be.equal('foo') + + window.requestAnimationFrame(() => { + expect(document.activeElement === instance.$('input')).to.be.ok + done() + }) }) it('it preserves riot DOM events', () => { @@ -69,11 +78,9 @@ describe('@riotjs/hydrate', () => { const instance = hydrate(WithLoops)(root) instance.insertItems() - expect(root.querySelectorAll('p').length).to.be.equal(5) + expect(instance.$$('p')).to.have.length(5) instance.insertNestedItems() - expect(root.querySelectorAll('span').length).to.be.equal(5) + expect(instance.$$('span')).to.have.length(5) }) - - }) \ No newline at end of file