diff --git a/package.json b/package.json index 9ef6e4c459..98e391aabe 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/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; } diff --git a/src/js/abstract/base-component.js b/src/js/abstract/base-component.js index 06da7c477a..51a42a7e94 100644 --- a/src/js/abstract/base-component.js +++ b/src/js/abstract/base-component.js @@ -1,3 +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'; @@ -169,7 +171,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`); } @@ -220,24 +222,24 @@ export default class BaseComponent extends HTMLElement { * @return {type} description */ render() { // eslint-disable-line - const { _hasRendered: initial } = this; + const initial = !this._hasRendered; if (ENV !== PROD) { - lifecycleLogger(this.logLifecycle)(`willRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${!initial}`); + lifecycleLogger(this.logLifecycle)(`willRenderCallback -> ${this.nodeName}#${this._id} <- initial: ${initial}`); } - this.willRenderCallback(!initial); + this.willRenderCallback(initial); 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: ${initial}`); } const { _template: template } = this; try { // At initial rendering -> collect the light DOM first - if (!this._hasRendered) { + if (initial) { const childrenFragment = document.createDocumentFragment(); const lightDOMRefs = []; @@ -250,9 +252,17 @@ export default class BaseComponent extends HTMLElement { this.childrenFragment = childrenFragment; } else { // Reuse the light DOM for subsequent rendering this._lightDOMRefs.forEach((ref) => { + // Important: Once the light DOM is live it shouldn't be moved out + // instead make sure to clone it for incremental updates + 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(ref); + this.childrenFragment.appendChild(refClone); }); } @@ -278,13 +288,23 @@ 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 (initial) { + super.appendChild(renderFragment); + } else { + const wcClone = this.cloneNode(false); + + if (ENV !== PROD) { + lifecycleLogger(this.logLifecycle)(`+++ incremental update -> ${this.nodeName}#${this._id}\n`); + } + + wcClone._isMorphing = true; + wcClone.appendChild(renderFragment); + + this._isMorphing = true; + nanomorph(this, wcClone); + clearIsSameNode(); + this._isMorphing = false; } - super.appendChild(renderFragment); } catch (err) { if (err.message !== THROWED_ERROR) { console.error( // eslint-disable-line @@ -300,10 +320,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: ${initial}`); } - this.didRenderCallback(!initial); + this.didRenderCallback(initial); } /** @@ -384,7 +404,7 @@ export default class BaseComponent extends HTMLElement { * @param {Element} node */ appendChild(node) { - if (!this._hasTemplate || !this._hasRendered) { + if (this._isMorphing || !this._hasTemplate || !this._hasRendered) { super.appendChild(node); return; } @@ -394,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 new file mode 100644 index 0000000000..08d652e981 --- /dev/null +++ b/src/js/abstract/component-morph.js @@ -0,0 +1,173 @@ +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); + + if (!oldNode.skipChildren || !oldNode.skipChildren()) { + 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/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 = []; +} 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); + } + } +} 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/**',