diff --git a/snabbdom.js b/snabbdom.js index 309c492..d210e5b 100644 --- a/snabbdom.js +++ b/snabbdom.js @@ -1,3 +1,4 @@ +// jshint newcap: false (function (root, factory) { if (typeof define === 'function' && define.amd) { define([], factory); // AMD. Register as an anonymous module. @@ -8,25 +9,33 @@ } }(this, function () { +'use strict'; + var isArr = Array.isArray; + function isString(s) { return typeof s === 'string'; } +function isPrimitive(s) { return typeof s === 'string' || typeof s === 'number'; } function isUndef(s) { return s === undefined; } -function VNode(tag, props, children, text) { - return {tag: tag, props: props, children: children, text: text, elm: undefined}; +function VNode(tag, props, children, text, elm) { + var key = !isUndef(props) ? props.key : undefined; + return {tag: tag, props: props, children: children, + text: text, elm: elm, key: key}; } -var emptyNodeAt = VNode.bind(null, {style: {}, class: {}}, []); +function emptyNodeAt(elm) { + return VNode(elm.tagName, {style: {}, class: {}}, [], undefined, elm); +} var frag = document.createDocumentFragment(); var emptyNode = VNode(undefined, {style: {}, class: {}}, [], undefined); function h(selector, b, c) { var props = {}, children, tag, i; if (arguments.length === 3) { - props = b; children = isString(c) ? [c] : c; + props = b; children = isPrimitive(c) ? [c] : c; } else if (arguments.length === 2) { if (isArr(b)) { children = b; } - else if (isString(b)) { children = [b]; } + else if (isPrimitive(b)) { children = [b]; } else { props = b; } } // Parse selector @@ -40,19 +49,12 @@ function h(selector, b, c) { if (isArr(children)) { for (i = 0; i < children.length; ++i) { - if (isString(children[i])) children[i] = VNode(undefined, undefined, undefined, children[i]); + if (isPrimitive(children[i])) children[i] = VNode(undefined, undefined, undefined, children[i]); } } - return VNode(tag, props, children, undefined, undefined); } -function setStyles(elm, styles) { - for (var key in styles) { - elm.style[key] = styles[key]; - } -} - function updateProps(elm, oldProps, props) { var key, val, name, on; for (key in props) { @@ -71,7 +73,7 @@ function updateProps(elm, oldProps, props) { elm.classList[on ? 'add' : 'remove'](name); } } - } else { + } else if (key !== 'key') { elm[key] = val; } } @@ -95,68 +97,119 @@ function createElm(vnode) { return elm; } -function sameElm(vnode1, vnode2) { - var key1 = isUndef(vnode1.props) ? undefined : vnode1.props.key; - var key2 = isUndef(vnode2.props) ? undefined : vnode2.props.key; - return key1 === key2 && vnode1.tag === vnode2.tag; +function sameVnode(vnode1, vnode2) { + return vnode1.key === vnode2.key && vnode1.tag === vnode2.tag; +} + +function createKeyToOldIdx(children, beginIdx, endIdx) { + var i, map = {}; + for (i = beginIdx; i <= endIdx; ++i) { + var ch = children[i]; + if (!isUndef(ch.props) && !isUndef(ch.props.key)) { + map[ch.props.key] = i; + } + } + return map; } function updateChildren(parentElm, oldCh, newCh) { - if (isUndef(oldCh) && isUndef(newCh)) { - return; // Neither new nor old element has any children + var oldStartIdx = 0, oldEndIdx, oldStartVnode, oldEndVnode; + if (isUndef(oldCh)) { + oldEndIdx = -1; + } else { + oldEndIdx = oldCh.length - 1; + oldStartVnode = oldCh[0]; + oldEndVnode = oldCh[oldEndIdx]; } - var oldStartPtr = 0, oldEndPtr = oldCh.length - 1; - var newStartPtr = 0, newEndPtr = newCh.length - 1; - var oldStartElm = oldCh[0], oldEndElm = oldCh[oldEndPtr]; - var newStartElm = newCh[0], newEndElm = newCh[newEndPtr]; - var success = true; - - var succes = true; - while (success && oldStartPtr <= oldEndPtr && newStartPtr <= newEndPtr) { - success = false; - if (sameElm(oldStartElm, newStartElm)) { - oldStartElm = oldCh[++oldStartPtr]; - newStartElm = newCh[++newStartPtr]; - success = true; - } - if (sameElm(oldEndElm, newEndElm)) { - oldEndElm = oldCh[--oldEndPtr]; - newEndElm = newCh[--newEndPtr]; - success = true; - } - if (!isUndef(oldStartElm) && !isUndef(newEndElm) && - sameElm(oldStartElm, newEndElm)) { // Elm moved forward - var beforeElm = oldEndElm.elm.nextSibling; - parentElm.insertBefore(oldStartElm.elm, beforeElm); - oldStartElm = oldCh[++oldStartPtr]; - newEndElm = newCh[--newEndPtr]; - success = true; + + var newStartIdx = 0, newEndIdx, newStartVnode, newEndVnode; + if (isUndef(newCh)) { + newEndIdx = -1; + } else { + newEndIdx = newCh.length - 1; + newStartVnode = newCh[0]; + newEndVnode = newCh[newEndIdx]; + } + + var oldKeyToIdx; + + while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { + if (isUndef(oldStartVnode)) { + oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left + } else if (isUndef(oldEndVnode)) { + oldEndVnode = oldCh[--oldEndIdx]; + } else if (sameVnode(oldStartVnode, newStartVnode)) { + while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx && + !isUndef(oldStartVnode) && sameVnode(oldStartVnode, newStartVnode)) { + patchElm(oldStartVnode, newStartVnode); + oldStartVnode = oldCh[++oldStartIdx]; + newStartVnode = newCh[++newStartIdx]; + } + } else if (sameVnode(oldEndVnode, newEndVnode)) { + patchElm(oldEndVnode, newEndVnode); + oldEndVnode = oldCh[--oldEndIdx]; + newEndVnode = newCh[--newEndIdx]; + } else if (!isUndef(oldStartVnode) && !isUndef(newEndVnode) && + sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right + patchElm(oldStartVnode, newEndVnode); + parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling); + oldStartVnode = oldCh[++oldStartIdx]; + newEndVnode = newCh[--newEndIdx]; + } else if (!isUndef(oldEndVnode) && !isUndef(newStartVnode) && + sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left + patchElm(oldEndVnode, newStartVnode); + parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm); + oldEndVnode = oldCh[--oldEndIdx]; + newStartVnode = newCh[++newStartIdx]; + } else { + if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); + var idxInOld = oldKeyToIdx[newStartVnode.key]; + if (isUndef(idxInOld)) { // New element + createElm(newStartVnode); + parentElm.insertBefore(newStartVnode.elm, oldStartVnode.elm); + newStartVnode = newCh[++newStartIdx]; + } else { + var elmToMove = oldCh[idxInOld]; + patchElm(elmToMove, newStartVnode); + oldCh[idxInOld] = undefined; + parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm); + newStartVnode = newCh[++newStartIdx]; + } } } - if (oldStartPtr > oldEndPtr) { // Done with old elements - for (; newStartPtr <= newEndPtr; ++newStartPtr) { - frag.appendChild(createElm(newCh[newStartPtr])); + if (oldStartIdx > oldEndIdx) { // Done with old elements + for (; newStartIdx <= newEndIdx; ++newStartIdx) { + frag.appendChild(createElm(newCh[newStartIdx])); } - if (isUndef(oldStartElm)) { + if (isUndef(oldStartVnode)) { parentElm.appendChild(frag); } else { - parentElm.insertBefore(frag, oldStartElm.elm); + parentElm.insertBefore(frag, oldStartVnode.elm); } - } else if (newStartPtr > newEndPtr) { // Done with new elements - for (; oldStartPtr <= oldEndPtr; ++oldStartPtr) { - parentElm.removeChild(oldCh[oldStartPtr].elm); - oldCh[oldStartPtr].elm = undefined; + } else if (newStartIdx > newEndIdx) { // Done with new elements + for (; oldStartIdx <= oldEndIdx; ++oldStartIdx) { + var ch = oldCh[oldStartIdx]; + if (!isUndef(ch)) { + parentElm.removeChild(oldCh[oldStartIdx].elm); + oldCh[oldStartIdx].elm = undefined; + } } } } function patchElm(oldVnode, newVnode) { - var elm = oldVnode.elm; - updateProps(elm, oldVnode.props, newVnode.props); - updateChildren(elm, oldVnode.children, newVnode.children); + var elm = newVnode.elm = oldVnode.elm; + if (isUndef(newVnode.text)) { + updateProps(elm, oldVnode.props, newVnode.props); + updateChildren(elm, oldVnode.children, newVnode.children); + } else { + if (oldVnode.text !== newVnode.text) { + elm.textContent = newVnode.text; + } + } return newVnode; } -return {h: h, createElm: createElm, patchElm: patchElm}; +return {h: h, createElm: createElm, patchElm: patchElm, patch: patchElm, emptyNodeAt: emptyNodeAt, emptyNode: emptyNode}; })); diff --git a/test/index.js b/test/index.js index 9d97bdd..c37012f 100644 --- a/test/index.js +++ b/test/index.js @@ -1,10 +1,27 @@ var assert = require('assert'); +var shuffle = require('knuth-shuffle').knuthShuffle; var snabbdom = require('../snabbdom'); var createElm = snabbdom.createElm; var patchElm = snabbdom.patchElm; var h = snabbdom.h; +function prop(name) { + return function(obj) { + return obj[name]; + }; +} + +function map(fn, list) { + var ret = []; + for (var i = 0; i < list.length; ++i) { + ret[i] = fn(list[i]); + } + return ret; +} + +var inner = prop('innerHTML'); + describe('snabbdom', function() { describe('hyperscript', function() { it('can create vnode with proper tag', function() { @@ -48,6 +65,12 @@ describe('snabbdom', function() { var vnode = h('a', {}, 'I am a string'); assert.equal(vnode.children[0].text, 'I am a string'); }); + it('can create empty vnode at element', function() { + var elm = document.createElement('div'); + var vnode = snabbdom.emptyNodeAt(elm); + console.log(vnode); + assert.equal(vnode.elm, elm); + }); }); describe('created element', function() { it('has tag', function() { @@ -78,7 +101,7 @@ describe('snabbdom', function() { }); it('can create elements with text content', function() { var elm = createElm(h('a', ['I am a string'])); - //console.log(elm.innerHTML); + assert.equal(elm.innerHTML, 'I am a string'); }); }); describe('pathing an element', function() { @@ -103,12 +126,16 @@ describe('snabbdom', function() { it('updates styles', function() { var vnode1 = h('i', {style: {fontSize: '14px', display: 'inline'}}); var vnode2 = h('i', {style: {fontSize: '12px', display: 'block'}}); + var vnode3 = h('i', {style: {fontSize: '10px', display: 'block'}}); var elm = createElm(vnode1); assert.equal(elm.style.fontSize, '14px'); assert.equal(elm.style.display, 'inline'); patchElm(vnode1, vnode2); assert.equal(elm.style.fontSize, '12px'); assert.equal(elm.style.display, 'block'); + patchElm(vnode2, vnode3); + assert.equal(elm.style.fontSize, '10px'); + assert.equal(elm.style.display, 'block'); }); describe('updating children with keys', function() { function spanNum(n) { return h('span', {key: n}, n.toString()); } @@ -124,15 +151,12 @@ describe('snabbdom', function() { assert.equal(elm.children[2].innerHTML, '3'); }); it('prepends elements', function() { - var vnode1 = h('span', [3].map(spanNum)); - var vnode2 = h('span', [1, 2, 3].map(spanNum)); + var vnode1 = h('span', [4, 5].map(spanNum)); + var vnode2 = h('span', [1, 2, 3, 4, 5].map(spanNum)); var elm = createElm(vnode1); - assert.equal(elm.children.length, 1); + assert.equal(elm.children.length, 2); patchElm(vnode1, vnode2); - assert.equal(elm.children.length, 3); - console.log(elm.children); - assert.equal(elm.children[1].innerHTML, '2'); - assert.equal(elm.children[2].innerHTML, '3'); + assert.deepEqual(map(inner, elm.children), ['1', '2', '3', '4', '5']); }); it('add elements in the middle', function() { var vnode1 = h('span', [1, 2, 4, 5].map(spanNum)); @@ -140,12 +164,31 @@ describe('snabbdom', function() { var elm = createElm(vnode1); assert.equal(elm.children.length, 4); patchElm(vnode1, vnode2); - assert.equal(elm.children.length, 5); - assert.equal(elm.children[0].innerHTML, '1'); - assert.equal(elm.children[1].innerHTML, '2'); - assert.equal(elm.children[2].innerHTML, '3'); - assert.equal(elm.children[3].innerHTML, '4'); - assert.equal(elm.children[4].innerHTML, '5'); + assert.deepEqual(map(inner, elm.children), ['1', '2', '3', '4', '5']); + }); + it('add elements at begin and end', function() { + var vnode1 = h('span', [2, 3, 4].map(spanNum)); + var vnode2 = h('span', [1, 2, 3, 4, 5].map(spanNum)); + var elm = createElm(vnode1); + assert.equal(elm.children.length, 3); + patchElm(vnode1, vnode2); + assert.deepEqual(map(inner, elm.children), ['1', '2', '3', '4', '5']); + }); + it('adds children to parent with no children', function() { + var vnode1 = h('span', {key: 'span'}); + var vnode2 = h('span', {key: 'span'}, [1, 2, 3].map(spanNum)); + var elm = createElm(vnode1); + assert.equal(elm.children.length, 0); + patchElm(vnode1, vnode2); + assert.deepEqual(map(inner, elm.children), ['1', '2', '3']); + }); + it('removes children from parent', function() { + var vnode1 = h('span', {key: 'span'}, [1, 2, 3].map(spanNum)); + var vnode2 = h('span', {key: 'span'}); + var elm = createElm(vnode1); + assert.deepEqual(map(inner, elm.children), ['1', '2', '3']); + patchElm(vnode1, vnode2); + assert.equal(elm.children.length, 0); }); }); describe('removal of elements', function() { @@ -155,10 +198,7 @@ describe('snabbdom', function() { var elm = createElm(vnode1); assert.equal(elm.children.length, 5); patchElm(vnode1, vnode2); - assert.equal(elm.children.length, 3); - assert.equal(elm.children[0].innerHTML, '3'); - assert.equal(elm.children[1].innerHTML, '4'); - assert.equal(elm.children[2].innerHTML, '5'); + assert.deepEqual(map(inner, elm.children), ['3', '4', '5']); }); it('removes elements from the end', function() { var vnode1 = h('span', [1, 2, 3, 4, 5].map(spanNum)); @@ -178,6 +218,7 @@ describe('snabbdom', function() { assert.equal(elm.children.length, 5); patchElm(vnode1, vnode2); assert.equal(elm.children.length, 4); + assert.deepEqual(elm.children[0].innerHTML, '1'); assert.equal(elm.children[0].innerHTML, '1'); assert.equal(elm.children[1].innerHTML, '2'); assert.equal(elm.children[2].innerHTML, '4'); @@ -208,15 +249,149 @@ describe('snabbdom', function() { assert.equal(elm.children[1].innerHTML, '3'); assert.equal(elm.children[2].innerHTML, '1'); }); + it('moves element backwards', function() { + var vnode1 = h('span', [1, 2, 3, 4].map(spanNum)); + var vnode2 = h('span', [1, 4, 2, 3].map(spanNum)); + var elm = createElm(vnode1); + assert.equal(elm.children.length, 4); + patchElm(vnode1, vnode2); + assert.equal(elm.children.length, 4); + assert.equal(elm.children[0].innerHTML, '1'); + assert.equal(elm.children[1].innerHTML, '4'); + assert.equal(elm.children[2].innerHTML, '2'); + assert.equal(elm.children[3].innerHTML, '3'); + }); + it('swaps first and last', function() { + var vnode1 = h('span', [1, 2, 3, 4].map(spanNum)); + var vnode2 = h('span', [4, 2, 3, 1].map(spanNum)); + var elm = createElm(vnode1); + assert.equal(elm.children.length, 4); + patchElm(vnode1, vnode2); + assert.equal(elm.children.length, 4); + assert.equal(elm.children[0].innerHTML, '4'); + assert.equal(elm.children[1].innerHTML, '2'); + assert.equal(elm.children[2].innerHTML, '3'); + assert.equal(elm.children[3].innerHTML, '1'); + }); + }); + describe('combinations of additions, removals and reorderings', function() { + it('move to left and replace', function() { + var vnode1 = h('span', [1, 2, 3, 4, 5].map(spanNum)); + var vnode2 = h('span', [4, 1, 2, 3, 6].map(spanNum)); + var elm = createElm(vnode1); + assert.equal(elm.children.length, 5); + patchElm(vnode1, vnode2); + assert.equal(elm.children.length, 5); + assert.equal(elm.children[0].innerHTML, '4'); + assert.equal(elm.children[1].innerHTML, '1'); + assert.equal(elm.children[2].innerHTML, '2'); + assert.equal(elm.children[3].innerHTML, '3'); + assert.equal(elm.children[4].innerHTML, '6'); + }); + it('move to left and leave hole', function() { + var vnode1 = h('span', [1, 4, 5].map(spanNum)); + var vnode2 = h('span', [4, 6].map(spanNum)); + var elm = createElm(vnode1); + assert.equal(elm.children.length, 3); + patchElm(vnode1, vnode2); + assert.equal(elm.children.length, 2); + assert.equal(elm.children[0].innerHTML, '4'); + assert.equal(elm.children[1].innerHTML, '6'); + }); + it('handles moved and set to undefined element ending at the end', function() { + var vnode1 = h('span', [2, 4, 5].map(spanNum)); + var vnode2 = h('span', [4, 5, 3].map(spanNum)); + var elm = createElm(vnode1); + assert.equal(elm.children.length, 3); + patchElm(vnode1, vnode2); + assert.equal(elm.children.length, 3); + assert.equal(elm.children[0].innerHTML, '4'); + assert.equal(elm.children[1].innerHTML, '5'); + assert.equal(elm.children[2].innerHTML, '3'); + }); + }); + it('reverses elements', function() { + var vnode1 = h('span', [1, 2, 3, 4].map(spanNum)); + var vnode2 = h('span', [4, 3, 2, 1].map(spanNum)); + var elm = createElm(vnode1); + assert.equal(elm.children.length, 4); + patchElm(vnode1, vnode2); + assert.equal(elm.children.length, 4); + assert.equal(elm.children[0].innerHTML, '4'); + assert.equal(elm.children[1].innerHTML, '3'); + assert.equal(elm.children[2].innerHTML, '2'); + assert.equal(elm.children[3].innerHTML, '1'); + }); + it('handles random shuffles', function() { + var n, i, arr = [], opacities = [], elms = 15, samples = 5; + function spanNumWithOpacity(n, o) { + return h('span', {key: n, style: {opacity: o}}, n.toString()); + } + for (n = 0; n < elms; ++n) { arr[n] = n; } + for (n = 0; n < samples; ++n) { + var vnode1 = h('span', arr.map(function(n) { + return spanNumWithOpacity(n, '1'); + })); + var shufArr = shuffle(arr.slice(0)); + var elm = createElm(vnode1); + for (i = 0; i < elms; ++i) { + assert.equal(elm.children[i].innerHTML, i.toString()); + opacities[i] = Math.random().toFixed(5).toString(); + } + var vnode2 = h('span', arr.map(function(n) { + return spanNumWithOpacity(shufArr[n], opacities[n]); + })); + patchElm(vnode1, vnode2); + for (i = 0; i < elms; ++i) { + assert.equal(elm.children[i].innerHTML, shufArr[i].toString()); + assert.equal(opacities[i].indexOf(elm.children[i].style.opacity), 0); + } + } }); - it('reverses elements'); }); describe('updating children without keys', function() { - it('appends elements'); - it('prepends elements'); - it('removes elements'); - it('reorders elements'); - it('reverses elements'); + it('appends elements', function() { + var vnode1 = h('div', [h('span', 'Hello')]); + var vnode2 = h('div', [h('span', 'Hello'), h('span', 'World')]); + var elm = createElm(vnode1); + assert.deepEqual(map(inner, elm.children), ['Hello']); + patchElm(vnode1, vnode2); + assert.deepEqual(map(inner, elm.children), ['Hello', 'World']); + }); + it('prepends element', function() { + var vnode1 = h('div', [h('span', 'World')]); + var vnode2 = h('div', [h('span', 'Hello'), h('span', 'World')]); + var elm = createElm(vnode1); + assert.deepEqual(map(inner, elm.children), ['World']); + patchElm(vnode1, vnode2); + assert.deepEqual(map(inner, elm.children), ['Hello', 'World']); + }); + it('prepends element of different tag type', function() { + var vnode1 = h('div', [h('span', 'World')]); + var vnode2 = h('div', [h('div', 'Hello'), h('span', 'World')]); + var elm = createElm(vnode1); + assert.deepEqual(map(inner, elm.children), ['World']); + patchElm(vnode1, vnode2); + assert.deepEqual(map(prop('tagName'), elm.children), ['DIV', 'SPAN']); + assert.deepEqual(map(inner, elm.children), ['Hello', 'World']); + }); + it('removes elements', function() { + var vnode1 = h('div', [h('span', 'One'), h('span', 'Two'), h('span', 'Three')]); + var vnode2 = h('div', [h('span', 'One'), h('span', 'Three')]); + var elm = createElm(vnode1); + assert.deepEqual(map(inner, elm.children), ['One', 'Two', 'Three']); + patchElm(vnode1, vnode2); + assert.deepEqual(map(inner, elm.children), ['One', 'Three']); + }); + it('reorders elements', function() { + var vnode1 = h('div', [h('span', 'One'), h('div', 'Two'), h('b', 'Three')]); + var vnode2 = h('div', [h('b', 'Three'), h('span', 'One'), h('div', 'Two')]); + var elm = createElm(vnode1); + assert.deepEqual(map(inner, elm.children), ['One', 'Two', 'Three']); + patchElm(vnode1, vnode2); + assert.deepEqual(map(prop('tagName'), elm.children), ['B', 'SPAN', 'DIV']); + assert.deepEqual(map(inner, elm.children), ['Three', 'One', 'Two']); + }); }); }); });