Skip to content

Commit

Permalink
Merge pull request #411 from axa-ch/bugfix/real-incremental-diff-base…
Browse files Browse the repository at this point in the history
…d-rendering

Bugfix/real incremental diff based rendering
  • Loading branch information
AndyOGo authored May 1, 2018
2 parents 5f7f60e + 2291fb0 commit ec5bcdc
Show file tree
Hide file tree
Showing 8 changed files with 411 additions and 18 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/components/m-header-navigation/js/stroke.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
61 changes: 45 additions & 16 deletions src/js/abstract/base-component.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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`);
}
Expand Down Expand Up @@ -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 = [];

Expand All @@ -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);
});
}

Expand All @@ -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
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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;
}
Expand All @@ -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.
Expand Down
173 changes: 173 additions & 0 deletions src/js/abstract/component-morph.js
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 31 additions & 0 deletions src/js/abstract/is-same-node-once.js
Original file line number Diff line number Diff line change
@@ -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 = [];
}
Loading

0 comments on commit ec5bcdc

Please sign in to comment.