From 74c5a6087beff06c5b1d9ac3efe795425b747c2e Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Wed, 18 Apr 2018 14:27:49 +0200 Subject: [PATCH 01/22] npm i nanomorph --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 9ef6e4c459..19015761df 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "core-js": "~2.5.1", "innersvg-polyfill": "0.0.2", "nanohtml": "^1.2.2", + "nanomorph": "^5.1.3", "react": "^16.2.0", "react-dom": "^16.2.0" } From ea8479bd21b78c481f8b04477334ee4588d5ef8c Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Wed, 18 Apr 2018 15:46:06 +0200 Subject: [PATCH 02/22] start adding incremental updates - firefox svg still broken --- src/js/abstract/base-component.js | 63 +++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 06da7c477a..88bc2908d0 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -1,3 +1,4 @@ +import nanomorph from 'nanomorph'; import getAttribute from '../get-attribute'; import toProp from '../to-prop'; import { publish, subscribe } from '../pubsub'; @@ -220,29 +221,31 @@ export default class BaseComponent extends HTMLElement { * @return {type} description */ render() { // eslint-disable-line - const { _hasRendered: initial } = this; + const { _hasRendered: notInitial } = this; if (ENV !== PROD) { - lifecycleLogger(this.logLifecycle)(`willRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${!initial}`); + lifecycleLogger(this.logLifecycle)(`willRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${!notInitial}`); } - this.willRenderCallback(!initial); + this.willRenderCallback(!notInitial); if (this._hasTemplate) { if (ENV !== PROD) { - lifecycleLogger(this.logLifecycle)(`render -> ${this.nodeName}#${this._id} <- initial: ${!this._hasRendered}`); + lifecycleLogger(this.logLifecycle)(`render -> ${this.nodeName}#${this._id} <- initial: ${!notInitial}`); } const { _template: template } = this; try { - // At initial rendering -> collect the light DOM first - if (!this._hasRendered) { + // At notInitial rendering -> collect the light DOM first + if (!notInitial) { const childrenFragment = document.createDocumentFragment(); const lightDOMRefs = []; while (this.firstChild) { lightDOMRefs.push(this.firstChild); + // Another piece of code is managing that part of the DOM tree. + isSameNodeOnce(this.firstChild); childrenFragment.appendChild(this.firstChild); } @@ -250,6 +253,9 @@ export default class BaseComponent extends HTMLElement { this.childrenFragment = childrenFragment; } else { // Reuse the light DOM for subsequent rendering this._lightDOMRefs.forEach((ref) => { + // Another piece of code is managing that part of the DOM tree. + isSameNodeOnce(ref); + // Note: DocumentFragments always get emptied after being appended to another document (they get moved) // so we can always reuse this this.childrenFragment.appendChild(ref); @@ -278,13 +284,22 @@ export default class BaseComponent extends HTMLElement { renderFragment.appendChild(items); } - // rebuild the whole DOM subtree - // @todo: this will break/disconnect previous DOM references, associated events and stuff like that - // @todo: may need to be improved by DOM diffing, JSX, whatever - while (this.firstChild) { - this.removeChild(this.firstChild); + if (!notInitial) { + super.appendChild(renderFragment); + } else { + const wcClone = this.cloneNode(false); + + if (ENV !== PROD) { + lifecycleLogger(this.logLifecycle)(`+++ incremental update -> ${this.nodeName}#${this._id}\n`); + } + + wcClone._morphing = true; + wcClone.appendChild(renderFragment); + + this._morphing = true; + nanomorph(this, wcClone); + this._morphing = false; } - super.appendChild(renderFragment); } catch (err) { if (err.message !== THROWED_ERROR) { console.error( // eslint-disable-line @@ -300,10 +315,10 @@ export default class BaseComponent extends HTMLElement { this._hasRendered = true; if (ENV !== PROD) { - lifecycleLogger(this.logLifecycle)(`didRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${!initial}`); + lifecycleLogger(this.logLifecycle)(`didRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${!notInitial}`); } - this.didRenderCallback(!initial); + this.didRenderCallback(!notInitial); } /** @@ -384,7 +399,7 @@ export default class BaseComponent extends HTMLElement { * @param {Element} node */ appendChild(node) { - if (!this._hasTemplate || !this._hasRendered) { + if (this._morphing || !this._hasTemplate || !this._hasRendered) { super.appendChild(node); return; } @@ -468,3 +483,21 @@ export default class BaseComponent extends HTMLElement { }); } } + +/** + * Make sure that another piece of code is/can managing that part of the DOM tree. + * + * @link https://github.com/choojs/nanomorph#caching-dom-elements + * @param node + */ +function isSameNodeOnce(node) { + const { isSameNode } = node; + + node.isSameNode = isSameNodeStopMorp; + + function isSameNodeStopMorp() { + node.isSameNode = isSameNode; + + return true; + } +} From 089b93b19ac6d0c1499c862ad6b56682e7496137 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Wed, 18 Apr 2018 16:01:23 +0200 Subject: [PATCH 03/22] fixed possible memory leak --- src/js/abstract/base-component.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 88bc2908d0..c5f2ce37cd 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -493,6 +493,9 @@ export default class BaseComponent extends HTMLElement { function isSameNodeOnce(node) { const { isSameNode } = node; + // make sure to not create unlimited chain of monkey patches + node.isSameNode(node); + node.isSameNode = isSameNodeStopMorp; function isSameNodeStopMorp() { From e312d7f3da3c1b34034d3ab2cdeba07e5afb209f Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Wed, 18 Apr 2018 16:04:22 +0200 Subject: [PATCH 04/22] fixed typo --- src/js/abstract/base-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index c5f2ce37cd..292b30c10b 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -237,7 +237,7 @@ export default class BaseComponent extends HTMLElement { const { _template: template } = this; try { - // At notInitial rendering -> collect the light DOM first + // At initial rendering -> collect the light DOM first if (!notInitial) { const childrenFragment = document.createDocumentFragment(); const lightDOMRefs = []; From 81bfa9d36aadde1e29ba2cd9dc05ba60e1e57ecc Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Thu, 19 Apr 2018 08:57:09 +0200 Subject: [PATCH 05/22] if node are live dont move them to fraqment --- src/js/abstract/base-component.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 292b30c10b..7767285bf8 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -256,9 +256,11 @@ export default class BaseComponent extends HTMLElement { // Another piece of code is managing that part of the DOM tree. isSameNodeOnce(ref); + const isLive = this.contains(ref); + // Note: DocumentFragments always get emptied after being appended to another document (they get moved) // so we can always reuse this - this.childrenFragment.appendChild(ref); + this.childrenFragment.appendChild(isLive ? ref.cloneNode(false) : ref); }); } From 8a0084429fcde6ddbb152ab37de4f0e5a4bb86ba Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Thu, 19 Apr 2018 08:58:32 +0200 Subject: [PATCH 06/22] added doclet --- src/js/abstract/base-component.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 7767285bf8..4eb199de64 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -256,6 +256,8 @@ export default class BaseComponent extends HTMLElement { // Another piece of code is managing that part of the DOM tree. isSameNodeOnce(ref); + // Important: Once the light DOM is live it shouldn't be moved out + // instead make sure to clone it for incremental updates const isLive = this.contains(ref); // Note: DocumentFragments always get emptied after being appended to another document (they get moved) From 3601a23509503967e9e18f6c3e0c595b7c23c151 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Thu, 19 Apr 2018 09:09:32 +0200 Subject: [PATCH 07/22] improved isSameNode once --- src/js/abstract/base-component.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 4eb199de64..53213ee804 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -495,15 +495,13 @@ export default class BaseComponent extends HTMLElement { * @param node */ function isSameNodeOnce(node) { - const { isSameNode } = node; - // make sure to not create unlimited chain of monkey patches node.isSameNode(node); node.isSameNode = isSameNodeStopMorp; function isSameNodeStopMorp() { - node.isSameNode = isSameNode; + delete node.isSameNode; return true; } From fe10811b8df70e75893534bb17337ae7466b3fd9 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 11:28:29 +0200 Subject: [PATCH 08/22] only return childnodes of component for morphing of current component --- src/js/abstract/base-component.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 53213ee804..6645018d7e 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -397,6 +397,17 @@ export default class BaseComponent extends HTMLElement { this.render(); } + /** + * Monkey patch `childNodes` API to re-rendering. + */ + get childNodes() { + if (this._morphing) { + return super.childNodes; + } + + return []; + } + /** * Monkey patch `appendChild` API to re-rendering. * From 9ab206fef742fcb105a9fe3cbb9f3b9d83d0041a Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 12:36:37 +0200 Subject: [PATCH 09/22] only patch childNodes if necessary --- src/js/abstract/base-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 6645018d7e..be2b51583c 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -401,7 +401,7 @@ export default class BaseComponent extends HTMLElement { * Monkey patch `childNodes` API to re-rendering. */ get childNodes() { - if (this._morphing) { + if (this._morphing || !this._hasTemplate || !this._hasRendered) { return super.childNodes; } From 4b4d18756cab4497517331fad7f45e2bd343e9bb Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 12:36:48 +0200 Subject: [PATCH 10/22] fixed broken stroke --- src/components/m-header-navigation/js/stroke.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/m-header-navigation/js/stroke.js b/src/components/m-header-navigation/js/stroke.js index c1d7d4263b..61f971307d 100644 --- a/src/components/m-header-navigation/js/stroke.js +++ b/src/components/m-header-navigation/js/stroke.js @@ -227,7 +227,9 @@ class Stroke extends UiEvents { this._offMoving(); if (this._stroke) { - this._stroke.parentNode.removeChild(this._stroke); + if (this._stroke.parentNode) { + this._stroke.parentNode.removeChild(this._stroke); + } delete this._stroke; } From 72cfcdd279ebdcdae2e31510818a93d1c7b0cc1c Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 13:46:40 +0200 Subject: [PATCH 11/22] fixed broken light DOM --- src/js/abstract/base-component.js | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index be2b51583c..59808facf9 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -253,16 +253,17 @@ export default class BaseComponent extends HTMLElement { this.childrenFragment = childrenFragment; } else { // Reuse the light DOM for subsequent rendering this._lightDOMRefs.forEach((ref) => { - // Another piece of code is managing that part of the DOM tree. - isSameNodeOnce(ref); - // Important: Once the light DOM is live it shouldn't be moved out // instead make sure to clone it for incremental updates - const isLive = this.contains(ref); + const refClone = ref.cloneNode(false); + + // Another piece of code is managing that part of the DOM tree. + isSameNodeOnce(ref); + isSameNodeOnce(refClone); // Note: DocumentFragments always get emptied after being appended to another document (they get moved) // so we can always reuse this - this.childrenFragment.appendChild(isLive ? ref.cloneNode(false) : ref); + this.childrenFragment.appendChild(refClone); }); } @@ -506,14 +507,9 @@ export default class BaseComponent extends HTMLElement { * @param node */ function isSameNodeOnce(node) { - // make sure to not create unlimited chain of monkey patches - node.isSameNode(node); - - node.isSameNode = isSameNodeStopMorp; - - function isSameNodeStopMorp() { - delete node.isSameNode; + node.isSameNode = isSameNodeStopMorph; + function isSameNodeStopMorph() { return true; } } From 1ffbbf88e8455fe5c021b5e0e8fe8fb46b4036f3 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 13:50:31 +0200 Subject: [PATCH 12/22] added todo --- src/js/abstract/base-component.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 59808facf9..fe4184e99e 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -506,6 +506,7 @@ export default class BaseComponent extends HTMLElement { * @link https://github.com/choojs/nanomorph#caching-dom-elements * @param node */ +// @todo: ideally this code is only attached during morphing phase function isSameNodeOnce(node) { node.isSameNode = isSameNodeStopMorph; From 5355f39457cdf0ec082a5c17117031963a68246e Mon Sep 17 00:00:00 2001 From: Luca Mele Date: Mon, 30 Apr 2018 15:30:23 +0200 Subject: [PATCH 13/22] Fix dependency --- package.json | 2 +- stack/tasks/bundle-demos.js | 1 + stack/tasks/bundles/_common.js | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 19015761df..1014da83f8 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "rollup-plugin-babel": "^3.0.2", "rollup-plugin-commonjs": "^8.2.6", "rollup-plugin-multi-entry": "^2.0.2", - "rollup-plugin-node-resolve": "^3.0.0", + "rollup-plugin-node-resolve": "3.0.0", "rollup-plugin-replace": "^2.0.0", "rollup-plugin-sass": "^0.5.3", "rollup-plugin-uglify": "^2.0.1", diff --git a/stack/tasks/bundle-demos.js b/stack/tasks/bundle-demos.js index 4a0b050e3e..c1b8e9df87 100644 --- a/stack/tasks/bundle-demos.js +++ b/stack/tasks/bundle-demos.js @@ -23,6 +23,7 @@ async function buildComponents() { jsnext: true, main: true, browser: true, + preferBuiltins: false, }), ENV === constants.ENV.PROD ? uglify() : () => {}, commonjs({ diff --git a/stack/tasks/bundles/_common.js b/stack/tasks/bundles/_common.js index 6dfca50570..ca6c45df9c 100644 --- a/stack/tasks/bundles/_common.js +++ b/stack/tasks/bundles/_common.js @@ -17,6 +17,7 @@ module.exports = { jsnext: true, main: true, browser: true, + preferBuiltins: false, }), commonjs({ include: 'node_modules/**', From e30a92c5d990560ca70e6540efae5c77d8516027 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 15:50:51 +0200 Subject: [PATCH 14/22] fixed wrong param order --- src/js/abstract/base-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index fe4184e99e..67e9bf1ae2 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -170,7 +170,7 @@ export default class BaseComponent extends HTMLElement { /** * Default behaviour is to re-render on attribute addition, change or removal. */ - attributeChangedCallback(name, newValue, oldValue) { + attributeChangedCallback(name, oldValue, newValue) { if (ENV !== PROD) { lifecycleLogger(this.logLifecycle)(`+++ attributeChangedCallback -> ${this.nodeName}#${this._id} | ${name} from ${oldValue} to ${newValue}\n`); } From f8f332c095804e3dc0642f2cb75d499e9fb41090 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 16:22:32 +0200 Subject: [PATCH 15/22] clearify notInitial --- src/js/abstract/base-component.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 67e9bf1ae2..4548401791 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -221,24 +221,24 @@ export default class BaseComponent extends HTMLElement { * @return {type} description */ render() { // eslint-disable-line - const { _hasRendered: notInitial } = this; + const initial = this._hasRendered; if (ENV !== PROD) { - lifecycleLogger(this.logLifecycle)(`willRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${!notInitial}`); + lifecycleLogger(this.logLifecycle)(`willRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${initial}`); } - this.willRenderCallback(!notInitial); + this.willRenderCallback(initial); if (this._hasTemplate) { if (ENV !== PROD) { - lifecycleLogger(this.logLifecycle)(`render -> ${this.nodeName}#${this._id} <- initial: ${!notInitial}`); + lifecycleLogger(this.logLifecycle)(`render -> ${this.nodeName}#${this._id} <- initial: ${initial}`); } const { _template: template } = this; try { // At initial rendering -> collect the light DOM first - if (!notInitial) { + if (initial) { const childrenFragment = document.createDocumentFragment(); const lightDOMRefs = []; @@ -289,7 +289,7 @@ export default class BaseComponent extends HTMLElement { renderFragment.appendChild(items); } - if (!notInitial) { + if (initial) { super.appendChild(renderFragment); } else { const wcClone = this.cloneNode(false); @@ -320,10 +320,10 @@ export default class BaseComponent extends HTMLElement { this._hasRendered = true; if (ENV !== PROD) { - lifecycleLogger(this.logLifecycle)(`didRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${!notInitial}`); + lifecycleLogger(this.logLifecycle)(`didRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${initial}`); } - this.didRenderCallback(!notInitial); + this.didRenderCallback(initial); } /** From e9ff7a958995e4334beb6709a49355fe883bc55a Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 16:23:09 +0200 Subject: [PATCH 16/22] prefixed with state --- src/js/abstract/base-component.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 4548401791..dc36d7ca93 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -298,12 +298,12 @@ export default class BaseComponent extends HTMLElement { lifecycleLogger(this.logLifecycle)(`+++ incremental update -> ${this.nodeName}#${this._id}\n`); } - wcClone._morphing = true; + wcClone._isMorphing = true; wcClone.appendChild(renderFragment); - this._morphing = true; + this._isMorphing = true; nanomorph(this, wcClone); - this._morphing = false; + this._isMorphing = false; } } catch (err) { if (err.message !== THROWED_ERROR) { @@ -402,7 +402,7 @@ export default class BaseComponent extends HTMLElement { * Monkey patch `childNodes` API to re-rendering. */ get childNodes() { - if (this._morphing || !this._hasTemplate || !this._hasRendered) { + if (this._isMorphing || !this._hasTemplate || !this._hasRendered) { return super.childNodes; } @@ -415,7 +415,7 @@ export default class BaseComponent extends HTMLElement { * @param {Element} node */ appendChild(node) { - if (this._morphing || !this._hasTemplate || !this._hasRendered) { + if (this._isMorphing || !this._hasTemplate || !this._hasRendered) { super.appendChild(node); return; } From 2bb6366913b35683b36b066d5a0a4700c01bb8c0 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 16:28:26 +0200 Subject: [PATCH 17/22] ooopps forgot to nigate --- src/js/abstract/base-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index dc36d7ca93..9162024141 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -221,7 +221,7 @@ export default class BaseComponent extends HTMLElement { * @return {type} description */ render() { // eslint-disable-line - const initial = this._hasRendered; + const initial = !this._hasRendered; if (ENV !== PROD) { lifecycleLogger(this.logLifecycle)(`willRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${initial}`); From a1cfb44174194ccc324edd21a823b3f6e5687609 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 11:21:44 +0200 Subject: [PATCH 18/22] copied nanomorph temporarely --- src/js/abstract/component-morph.js | 169 +++++++++++++++++++++++++++++ src/js/abstract/morph.js | 156 ++++++++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 src/js/abstract/component-morph.js create mode 100644 src/js/abstract/morph.js diff --git a/src/js/abstract/component-morph.js b/src/js/abstract/component-morph.js new file mode 100644 index 0000000000..4936edfca9 --- /dev/null +++ b/src/js/abstract/component-morph.js @@ -0,0 +1,169 @@ +import morph from './morph'; + +const TEXT_NODE = 3; +// var DEBUG = false + +export default componentMorph; + +// Morph one tree into another tree +// +// no parent +// -> same: diff and walk children +// -> not same: replace and return +// old node doesn't exist +// -> insert new node +// new node doesn't exist +// -> delete old node +// nodes are not the same +// -> diff nodes and apply patch to old node +// nodes are the same +// -> walk all child nodes and append to old node +function componentMorph(oldTree, newTree) { + // if (DEBUG) { + // console.log( + // 'componentMorph\nold\n %s\nnew\n %s', + // oldTree && oldTree.outerHTML, + // newTree && newTree.outerHTML + // ) + // } + if (typeof oldTree !== 'object') { + throw new Error('componentMorph: oldTree should be an object'); + } + + if (typeof newTree !== 'object') { + throw new Error('componentMorph: newTree should be an object'); + } + + const tree = walk(newTree, oldTree); + // if (DEBUG) console.log('=> morphed\n %s', tree.outerHTML) + return tree; +} + +// Walk and morph a dom tree +function walk(newNode, oldNode) { + // if (DEBUG) { + // console.log( + // 'walk\nold\n %s\nnew\n %s', + // oldNode && oldNode.outerHTML, + // newNode && newNode.outerHTML + // ) + // } + if (!oldNode) { + return newNode; + } else if (!newNode) { + return null; + } else if (newNode.isSameNode && newNode.isSameNode(oldNode)) { + return oldNode; + } else if (newNode.tagName !== oldNode.tagName) { + return newNode; + } + + morph(newNode, oldNode); + updateChildren(newNode, oldNode); + return oldNode; +} + +// Update the children of elements +// (obj, obj) -> null +function updateChildren(newNode, oldNode) { + // if (DEBUG) { + // console.log( + // 'updateChildren\nold\n %s\nnew\n %s', + // oldNode && oldNode.outerHTML, + // newNode && newNode.outerHTML + // ) + // } + let oldChild; + let newChild; + let morphed; + let oldMatch; + + // The offset is only ever increased, and used for [i - offset] in the loop + let offset = 0; + + /* eslint-disable no-plusplus */ + for (let i = 0; ; i++) { + oldChild = oldNode.childNodes[i]; + newChild = newNode.childNodes[i - offset]; + // if (DEBUG) { + // console.log( + // '===\n- old\n %s\n- new\n %s', + // oldChild && oldChild.outerHTML, + // newChild && newChild.outerHTML + // ) + // } + // Both nodes are empty, do nothing + if (!oldChild && !newChild) { + break; + + // There is no new child, remove old + } else if (!newChild) { + oldNode.removeChild(oldChild); + i--; + + // There is no old child, add new + } else if (!oldChild) { + oldNode.appendChild(newChild); + offset++; + + // Both nodes are the same, morph + } else if (same(newChild, oldChild)) { + morphed = walk(newChild, oldChild); + if (morphed !== oldChild) { + oldNode.replaceChild(morphed, oldChild); + offset++; + } + + // Both nodes do not share an ID or a placeholder, try reorder + } else { + oldMatch = null; + + // Try and find a similar node somewhere in the tree + for (let j = i; j < oldNode.childNodes.length; j++) { + if (same(oldNode.childNodes[j], newChild)) { + oldMatch = oldNode.childNodes[j]; + break; + } + } + + // If there was a node with the same ID or placeholder in the old list + if (oldMatch) { + morphed = walk(newChild, oldMatch); + if (morphed !== oldMatch) { + offset++; + } + + oldNode.insertBefore(morphed, oldChild); + + // It's safe to morph two nodes in-place if neither has an ID + } else if (!newChild.id && !oldChild.id) { + morphed = walk(newChild, oldChild); + if (morphed !== oldChild) { + oldNode.replaceChild(morphed, oldChild); + offset++; + } + + // Insert the node at the index if we couldn't morph or find a matching node + } else { + oldNode.insertBefore(newChild, oldChild); + offset++; + } + } + } +} + +function same(a, b) { + if (a.id) { + return a.id === b.id; + } + if (a.isSameNode) { + return a.isSameNode(b); + } + if (a.tagName !== b.tagName) { + return false; + } + if (a.type === TEXT_NODE) { + return a.nodeValue === b.nodeValue; + } + return false; +} diff --git a/src/js/abstract/morph.js b/src/js/abstract/morph.js new file mode 100644 index 0000000000..d4d9780ad0 --- /dev/null +++ b/src/js/abstract/morph.js @@ -0,0 +1,156 @@ +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const COMMENT_NODE = 8; + +export default morph; + +// diff elements and apply the resulting patch to the old node +// (obj, obj) -> null +function morph(newNode, oldNode) { + const { nodeType, nodeName } = newNode; + + if (nodeType === ELEMENT_NODE) { + copyAttrs(newNode, oldNode); + } + + if ((nodeType === TEXT_NODE || nodeType === COMMENT_NODE) && oldNode.nodeValue !== newNode.nodeValue) { + oldNode.nodeValue = newNode.nodeValue; + } + + // Some DOM nodes are weird + // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js + if (nodeName === 'INPUT') { + updateInput(newNode, oldNode); + } else if (nodeName === 'OPTION') { + updateOption(newNode, oldNode); + } else if (nodeName === 'TEXTAREA') { + updateTextarea(newNode, oldNode); + } +} + +function copyAttrs(newNode, oldNode) { + const { attributes: oldAttrs } = oldNode; + const { attributes: newAttrs } = newNode; + let attrNamespaceURI = null; + let attrValue = null; + let fromValue = null; + let attrName = null; + let attr = null; + + for (let i = newAttrs.length - 1; i >= 0; --i) { + attr = newAttrs[i]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + attrValue = attr.value; + + if (attrNamespaceURI) { + const attrLocalName = attr.localName; + + // Important: getAttributeNS expects the localName of a namespaced attribute + // ref: https://dom.spec.whatwg.org/#dom-element-getattributens + // ref: https://www.w3.org/TR/DOM-Level-2-Core/glossary.html#dt-localname + fromValue = oldNode.getAttributeNS(attrNamespaceURI, attrLocalName || attrName); + if (fromValue !== attrValue) { + // but setAttributeNS requires the fully qualified name + // ref: https://dom.spec.whatwg.org/#dom-element-setattributens + // ref: https://www.w3.org/TR/DOM-Level-2-Core/glossary.html#dt-qualifiedname + oldNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); + } + } else if (!oldNode.hasAttribute(attrName)) { + oldNode.setAttribute(attrName, attrValue); + } else { + fromValue = oldNode.getAttribute(attrName); + if (fromValue !== attrValue) { + // apparently values are always cast to strings, ah well + if (attrValue === 'null' || attrValue === 'undefined') { + oldNode.removeAttribute(attrName); + } else { + oldNode.setAttribute(attrName, attrValue); + } + } + } + } + + // Remove any extra attributes found on the original DOM element that + // weren't found on the target element. + for (let j = oldAttrs.length - 1; j >= 0; --j) { + attr = oldAttrs[j]; + + if (attr.specified !== false) { + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + if (!newNode.hasAttributeNS(attrNamespaceURI, attrName)) { + oldNode.removeAttributeNS(attrNamespaceURI, attrName); + } + } else if (!newNode.hasAttributeNS(null, attrName)) { + oldNode.removeAttribute(attrName); + } + } + } +} + +function updateOption(newNode, oldNode) { + updateAttribute(newNode, oldNode, 'selected'); +} + +// The "value" attribute is special for the 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". +function updateInput(newNode, oldNode) { + const { value: newValue } = newNode; + const { value: oldValue } = oldNode; + + updateAttribute(newNode, oldNode, 'checked'); + updateAttribute(newNode, oldNode, 'disabled'); + + if (newValue !== oldValue) { + oldNode.setAttribute('value', newValue); + oldNode.value = newValue; + } + + if (newValue === 'null') { + oldNode.value = ''; + oldNode.removeAttribute('value'); + } + + if (!newNode.hasAttributeNS(null, 'value')) { + oldNode.removeAttribute('value'); + } else if (oldNode.type === 'range') { + // this is so elements like slider move their UI thingy + oldNode.value = newValue; + } +} + +function updateTextarea(newNode, oldNode) { + const { value: newValue } = newNode; + + if (newValue !== oldNode.value) { + oldNode.value = newValue; + } + + if (oldNode.firstChild && oldNode.firstChild.nodeValue !== newValue) { + // Needed for IE. Apparently IE sets the placeholder as the + // node value and vise versa. This ignores an empty update. + if (newValue === '' && oldNode.firstChild.nodeValue === oldNode.placeholder) { + return; + } + + oldNode.firstChild.nodeValue = newValue; + } +} + +function updateAttribute(newNode, oldNode, name) { + if (newNode[name] !== oldNode[name]) { + oldNode[name] = newNode[name]; + + if (newNode[name]) { + oldNode.setAttribute(name, ''); + } else { + oldNode.removeAttribute(name); + } + } +} From dc98ba8cd61ebdeb70927f77b21c530fe21f87cf Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 17:35:51 +0200 Subject: [PATCH 19/22] fixed ot component scoped --- src/js/abstract/base-component.js | 22 ++++++++++------------ src/js/abstract/component-morph.js | 6 +++++- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 9162024141..9c723bbc69 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -1,4 +1,4 @@ -import nanomorph from 'nanomorph'; +import nanomorph from './component-morph'; import getAttribute from '../get-attribute'; import toProp from '../to-prop'; import { publish, subscribe } from '../pubsub'; @@ -398,17 +398,6 @@ export default class BaseComponent extends HTMLElement { this.render(); } - /** - * Monkey patch `childNodes` API to re-rendering. - */ - get childNodes() { - if (this._isMorphing || !this._hasTemplate || !this._hasRendered) { - return super.childNodes; - } - - return []; - } - /** * Monkey patch `appendChild` API to re-rendering. * @@ -425,6 +414,15 @@ export default class BaseComponent extends HTMLElement { this.render(); } + /** + * Only morph children of current custom element, not any other custom element. + * + * @returns {boolean} + */ + skipChildren() { + return !this._isMorphing; + } + // @TODO: atm no data can be shared by enabling context, though this could be necessary /** * Provides an opt-in contextual scope for hierarchy-agnostic child components. diff --git a/src/js/abstract/component-morph.js b/src/js/abstract/component-morph.js index 4936edfca9..4b908481ed 100644 --- a/src/js/abstract/component-morph.js +++ b/src/js/abstract/component-morph.js @@ -59,7 +59,11 @@ function walk(newNode, oldNode) { } morph(newNode, oldNode); - updateChildren(newNode, oldNode); + + if (!oldNode.skipChildren && !oldNode.skipChildren()) { + updateChildren(newNode, oldNode); + } + return oldNode; } From 28981a529091ec7e3ff5d38c47b1600a01264400 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 17:36:26 +0200 Subject: [PATCH 20/22] removed nanomorph --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 1014da83f8..98e391aabe 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,6 @@ "core-js": "~2.5.1", "innersvg-polyfill": "0.0.2", "nanohtml": "^1.2.2", - "nanomorph": "^5.1.3", "react": "^16.2.0", "react-dom": "^16.2.0" } From 52e3cc164e40cbecca25027d76a3c2f57460f5c4 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 17:51:44 +0200 Subject: [PATCH 21/22] fixed wrong binary op --- src/js/abstract/component-morph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/abstract/component-morph.js b/src/js/abstract/component-morph.js index 4b908481ed..08d652e981 100644 --- a/src/js/abstract/component-morph.js +++ b/src/js/abstract/component-morph.js @@ -60,7 +60,7 @@ function walk(newNode, oldNode) { morph(newNode, oldNode); - if (!oldNode.skipChildren && !oldNode.skipChildren()) { + if (!oldNode.skipChildren || !oldNode.skipChildren()) { updateChildren(newNode, oldNode); } From 2291fb00318482d2431893685f2eb9635054ab03 Mon Sep 17 00:00:00 2001 From: Andreas Deuschlinger Date: Mon, 30 Apr 2018 19:21:56 +0200 Subject: [PATCH 22/22] clean up is same node --- src/js/abstract/base-component.js | 19 ++--------------- src/js/abstract/is-same-node-once.js | 31 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 17 deletions(-) create mode 100644 src/js/abstract/is-same-node-once.js diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 9c723bbc69..51a42a7e94 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -1,4 +1,5 @@ import nanomorph from './component-morph'; +import { isSameNodeOnce, clearIsSameNode } from './is-same-node-once'; import getAttribute from '../get-attribute'; import toProp from '../to-prop'; import { publish, subscribe } from '../pubsub'; @@ -244,8 +245,6 @@ export default class BaseComponent extends HTMLElement { while (this.firstChild) { lightDOMRefs.push(this.firstChild); - // Another piece of code is managing that part of the DOM tree. - isSameNodeOnce(this.firstChild); childrenFragment.appendChild(this.firstChild); } @@ -303,6 +302,7 @@ export default class BaseComponent extends HTMLElement { this._isMorphing = true; nanomorph(this, wcClone); + clearIsSameNode(); this._isMorphing = false; } } catch (err) { @@ -497,18 +497,3 @@ export default class BaseComponent extends HTMLElement { }); } } - -/** - * Make sure that another piece of code is/can managing that part of the DOM tree. - * - * @link https://github.com/choojs/nanomorph#caching-dom-elements - * @param node - */ -// @todo: ideally this code is only attached during morphing phase -function isSameNodeOnce(node) { - node.isSameNode = isSameNodeStopMorph; - - function isSameNodeStopMorph() { - return true; - } -} diff --git a/src/js/abstract/is-same-node-once.js b/src/js/abstract/is-same-node-once.js new file mode 100644 index 0000000000..aa90784988 --- /dev/null +++ b/src/js/abstract/is-same-node-once.js @@ -0,0 +1,31 @@ +let sameNodeCache = []; + +/** + * Make sure that another piece of code is/can managing that part of the DOM tree. + * + * @link https://github.com/choojs/nanomorph#caching-dom-elements + * @param node + */ +export function isSameNodeOnce(node) { + node.isSameNode = isSameNodeStopMorph; + + sameNodeCache.push(node); + + function isSameNodeStopMorph() { + return true; + } +} + +/** + * Make sure to clear overwritten `isSameNode` API after DOM diffing. + */ +export function clearIsSameNode() { + let node; + + // eslint-disable-next-line no-cond-assign + while (node = sameNodeCache.pop()) { + delete node.isSameNode; + } + + sameNodeCache = []; +}