Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfix/real incremental diff based rendering #411

Merged
merged 22 commits into from
May 1, 2018
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
76 changes: 60 additions & 16 deletions src/js/abstract/base-component.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import nanomorph from './component-morph';
import getAttribute from '../get-attribute';
import toProp from '../to-prop';
import { publish, subscribe } from '../pubsub';
Expand Down Expand Up @@ -169,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`);
}
Expand Down Expand Up @@ -220,39 +221,49 @@ 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 = [];

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);
}

this._lightDOMRefs = lightDOMRefs;
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 +289,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 (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);
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 Expand Up @@ -468,3 +497,18 @@ 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node.isSameNode = () => true;


function isSameNodeStopMorph() {
return true;
}
}
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';
Copy link
Author

@AndyOGo AndyOGo Apr 30, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may will create it's own npm package for morphin in the future

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a todo in the file directly and also WHY we did copy/modify the nanomorph one


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;
}
Loading