From 977239d207a430275be182cd459acdc8625aaceb Mon Sep 17 00:00:00 2001 From: Leo Horie Date: Thu, 19 May 2016 23:24:04 -0400 Subject: [PATCH] rename limiter to throttle and refactor - don't inject raf/setTimeout since we can't really mock them w/ a good degree of timing accuracy anyways fix some unrelated tests --- api/limiter.js | 37 -- api/mount.js | 12 +- api/router.js | 11 +- api/tests/index.html | 6 +- api/tests/test-mount.js | 73 ++-- api/tests/test-router.js | 311 ++++++++--------- api/tests/test-throttle.js | 84 +++++ api/throttle.js | 23 ++ index.js | 2 + mithril.js | 561 ++++++++++++++++++++++++++++--- render/render.js | 2 +- render/tests/test-event.js | 2 +- render/tests/test-style.js | 0 test-input.html | 40 --- test-utils/tests/test-domMock.js | 2 +- 15 files changed, 813 insertions(+), 353 deletions(-) delete mode 100644 api/limiter.js create mode 100644 api/tests/test-throttle.js create mode 100644 api/throttle.js delete mode 100644 render/tests/test-style.js delete mode 100644 test-input.html diff --git a/api/limiter.js b/api/limiter.js deleted file mode 100644 index 64202901..00000000 --- a/api/limiter.js +++ /dev/null @@ -1,37 +0,0 @@ -"use strict" - -var FRAME_BUDGET = 16 // 60 frames per second = 1 call per 16 ms - -module.exports = function($window, render) { - var rAF = $window.requestAnimationFrame || $window.setTimeout - - var last = 0 - var pending = null - - return function(force) { - var now = new Date() - - // Immediately render if: - // Forced - // Haven't rendered yet - // Time since the last render is greater than the frame budget - if(force || !last || now - last > FRAME_BUDGET) { - last = now; - - return render() - } - - // Redraw already pending, abort - if(pending !== null) { - return - } - - // Schedule a redraw for the next tick - pending = rAF(function() { - render() - - last = new Date() - pending = null - }, FRAME_BUDGET - (now - last)) - } -} diff --git a/api/mount.js b/api/mount.js index f1526613..b42e3d93 100644 --- a/api/mount.js +++ b/api/mount.js @@ -1,18 +1,18 @@ "use strict" var createRenderer = require("../render/render") -var limiter = require("./limiter"); +var throttle = require("../api/throttle") module.exports = function($window, redraw) { + var renderer = createRenderer($window) return function(root, component) { - var renderer = createRenderer($window) - var draw = limiter($window, function draw() { + var run = throttle(function() { renderer.render(root, {tag: component}) }) - renderer.setEventCallback(draw) + renderer.setEventCallback(run) - redraw.run = draw - draw() + redraw.run = run + run() } } diff --git a/api/router.js b/api/router.js index 4da9795d..95eef3c1 100644 --- a/api/router.js +++ b/api/router.js @@ -2,20 +2,21 @@ var createRenderer = require("../render/render") var createRouter = require("../router/router") -var limiter = require("./limiter") +var throttle = require("../api/throttle") module.exports = function($window, redraw) { var renderer = createRenderer($window) var router = createRouter($window) var route = function(root, defaultRoute, routes) { - var replay = limiter($window, router.defineRoutes(routes, function(component, args) { + var replay = router.defineRoutes(routes, function(component, args) { renderer.render(root, {tag: component, attrs: args}) }, function() { router.setPath(defaultRoute) - })) + }) + var run = throttle(replay) - renderer.setEventCallback(replay) - redraw.run = replay + renderer.setEventCallback(run) + redraw.run = run } route.link = router.link route.prefix = router.setPrefix diff --git a/api/tests/index.html b/api/tests/index.html index bc0d8b51..c9217adb 100644 --- a/api/tests/index.html +++ b/api/tests/index.html @@ -19,13 +19,11 @@ - + - - - + diff --git a/api/tests/test-mount.js b/api/tests/test-mount.js index a9474761..7f6d00f1 100644 --- a/api/tests/test-mount.js +++ b/api/tests/test-mount.js @@ -2,29 +2,20 @@ var o = require("../../ospec/ospec") var domMock = require("../../test-utils/domMock") -var async = require("./async") var m = require("../../render/hyperscript") var createMounter = require("../mount") o.spec("m.mount", function() { + var FRAME_BUDGET = 1000 / 60 var $window, root o.beforeEach(function() { $window = domMock() - async.setTimeout($window) root = $window.document.body }) - o("is a function", function() { - o(typeof createMounter).equals("function") - }) - - o("returns a function after invocation", function() { - o(typeof createMounter()).equals("function") - }) - - o("updates passed in redraw object", function() { + o("updates redraw object", function() { var redraw = {} var mount = createMounter($window, redraw) @@ -49,34 +40,7 @@ o.spec("m.mount", function() { o(root.firstChild.nodeName).equals("DIV") }) - o("redraws on redraw.run()", function(done) { - var onupdate = o.spy() - var oninit = o.spy() - var redraw = {} - var mount = createMounter($window, redraw) - - mount(root, { - view : function() { - return m("div", { - oninit : oninit, - onupdate : onupdate - }) - } - }) - - o(oninit.callCount).equals(1) - - redraw.run() - - // Wrapped to give time for the rate-limited redraw to fire - setTimeout(function() { - o(onupdate.callCount).equals(1) - - done() - }, 20) - }) - - o("redraws on events", function(done, timeout) { + o("redraws on events", function(done) { var onupdate = o.spy() var oninit = o.spy() var onclick = o.spy() @@ -98,6 +62,7 @@ o.spec("m.mount", function() { root.firstChild.dispatchEvent(e) o(oninit.callCount).equals(1) + o(onupdate.callCount).equals(0) o(onclick.callCount).equals(1) o(onclick.this).equals(root.firstChild) @@ -109,6 +74,34 @@ o.spec("m.mount", function() { o(onupdate.callCount).equals(1) done() - }, 20) + }, FRAME_BUDGET) + }) + + o("redraws on redraw.run()", function(done) { + var onupdate = o.spy() + var oninit = o.spy() + var redraw = {} + var mount = createMounter($window, redraw) + + mount(root, { + view : function() { + return m("div", { + oninit : oninit, + onupdate : onupdate + }) + } + }) + + o(oninit.callCount).equals(1) + o(onupdate.callCount).equals(0) + + redraw.run() + + // Wrapped to give time for the rate-limited redraw to fire + setTimeout(function() { + o(onupdate.callCount).equals(1) + + done() + }, FRAME_BUDGET) }) }) diff --git a/api/tests/test-router.js b/api/tests/test-router.js index e7a0aab5..624e492c 100644 --- a/api/tests/test-router.js +++ b/api/tests/test-router.js @@ -3,189 +3,144 @@ var o = require("../../ospec/ospec") var pushStateMock = require("../../test-utils/pushStateMock") var domMock = require("../../test-utils/domMock") -var async = require("./async") var m = require("../../render/hyperscript") -// Convention would be `createRouter`, but that causes variable shadowing bugs -// in browsers when running tests, so `makeRouter` it is -var makeRouter = require("../router") +var router = require("../../api/router") o.spec("m.route", function() { - var $window, root, router + var FRAME_BUDGET = 1000 / 60 + var $window, root, route, redraw - void [ - "setTimeout", - "requestAnimationFrame" - ].forEach(function(timing) { - o.spec(timing, function() { - void [ - "#", - "?", - "#!", - "?!", - "" - ].forEach(function(prefix) { - var spec = prefix ? "prefix " + prefix : "pushstate"; - - o.spec(spec, function() { - o.beforeEach(function() { - var dom = domMock() - var location = pushStateMock() - - // Generate a DOM + Location mock - Object.keys(location).forEach(function(key) { - dom[key] = location[key] - }) - - $window = dom - async[timing]($window) - root = $window.document.body - }) - - o("is a function", function() { - o(typeof makeRouter).equals("function") - }) - - o("returns a function after invocation", function() { - o(typeof makeRouter($window)).equals("function") - }) - - o("updates passed in redraw object", function() { - var redraw = {} - var router = makeRouter($window, redraw) - - router.prefix(prefix) - - router(root, "/", { - "/" : { - view: function() { - return m("div") - } - } - }) - - o(typeof redraw.run).equals("function") - }) - - o("renders into `root`", function() { - var router = makeRouter($window, {}) - - router.prefix(prefix) - - router(root, "/", { - "/" : { - view: function() { - return m("div") - } - } - }) - - o(root.firstChild.nodeName).equals("DIV") - }) - - o("redraws on redraw.run()", function(done) { - var onupdate = o.spy() - var oninit = o.spy() - var redraw = {} - var router = makeRouter($window, redraw) - - router.prefix(prefix) - - router(root, "/", { - "/" : { - view: function() { - return m("div", { - oninit: oninit, - onupdate: onupdate - }) - } - } - }) - - o(oninit.callCount).equals(1) - - redraw.run() - - // Wrapped to give time for the rate-limited redraw to fire - setTimeout(function() { - o(onupdate.callCount).equals(1) - - done() - }, 20) - }) - - o("redraws on events", function(done, timeout) { - var onupdate = o.spy() - var oninit = o.spy() - var onclick = o.spy() - var router = makeRouter($window, {}) - var e = $window.document.createEvent("MouseEvents") - - e.initEvent("click", true, true) - - router.prefix(prefix) - - router(root, "/", { - "/" : { - view: function() { - return m("div", { - oninit: oninit, - onupdate: onupdate, - onclick: onclick, - }) - } - } - }) - - root.firstChild.dispatchEvent(e) - - o(oninit.callCount).equals(1) - - o(onclick.callCount).equals(1) - o(onclick.this).equals(root.firstChild) - o(onclick.args[0].type).equals("click") - o(onclick.args[0].target).equals(root.firstChild) - - // Wrapped to give time for the rate-limited redraw to fire - setTimeout(function() { - o(onupdate.callCount).equals(1) - - done() - }, 20) - }) - - o("changes location on route.link", function() { - var router = makeRouter($window, {}) - var e = $window.document.createEvent("MouseEvents") - - e.initEvent("click", true, true) - - router.prefix(prefix) - - router(root, "/", { - "/" : { - view: function() { - return m("a", { - href: "/test", - oncreate: router.link - }) - } - }, - "/test" : { - view : function() { - return m("div") - } - } - }) - - o($window.location.href).equals("http://localhost/" + (prefix ? prefix + "/" : "")) - - root.firstChild.dispatchEvent(e) - - o($window.location.href).equals("http://localhost/" + (prefix ? prefix + "/test" : "test")) - }) - }) - }) + o.beforeEach(function() { + $window = {} + + var dom = domMock() + for (var key in dom) $window[key] = dom[key] + + var loc = pushStateMock() + for (var key in loc) $window[key] = loc[key] + + root = $window.document.body + + redraw = {} + route = router($window, redraw) + }) + + o("updates redraw object", function() { + route(root, "/", { + "/" : { + view: function() { + return m("div") + } + } }) + + o(typeof redraw.run).equals("function") + }) + + o("renders into `root`", function() { + route(root, "/", { + "/" : { + view: function() { + return m("div") + } + } + }) + + o(root.firstChild.nodeName).equals("DIV") + }) + + o("redraws on redraw.run()", function(done) { + var onupdate = o.spy() + var oninit = o.spy() + + route(root, "/", { + "/" : { + view: function() { + return m("div", { + oninit: oninit, + onupdate: onupdate + }) + } + } + }) + + o(oninit.callCount).equals(1) + + redraw.run() + + // Wrapped to give time for the rate-limited redraw to fire + setTimeout(function() { + o(onupdate.callCount).equals(1) + + done() + }, FRAME_BUDGET) + }) + + o("redraws on events", function(done, timeout) { + var onupdate = o.spy() + var oninit = o.spy() + var onclick = o.spy() + var e = $window.document.createEvent("MouseEvents") + + e.initEvent("click", true, true) + + route(root, "/", { + "/" : { + view: function() { + return m("div", { + oninit: oninit, + onupdate: onupdate, + onclick: onclick, + }) + } + } + }) + + root.firstChild.dispatchEvent(e) + + o(oninit.callCount).equals(1) + + o(onclick.callCount).equals(1) + o(onclick.this).equals(root.firstChild) + o(onclick.args[0].type).equals("click") + o(onclick.args[0].target).equals(root.firstChild) + + // Wrapped to give time for the rate-limited redraw to fire + setTimeout(function() { + o(onupdate.callCount).equals(1) + + done() + }, FRAME_BUDGET) + }) + + o("changes location on route.link", function() { + var e = $window.document.createEvent("MouseEvents") + + e.initEvent("click", true, true) + + route.prefix("?") + + route(root, "/", { + "/" : { + view: function() { + return m("a", { + href: "/test", + oncreate: route.link + }) + } + }, + "/test" : { + view : function() { + return m("div") + } + } + }) + + o($window.location.href).equals("http://localhost/?/") + + root.firstChild.dispatchEvent(e) + + o($window.location.href).equals("http://localhost/?/test") }) }) diff --git a/api/tests/test-throttle.js b/api/tests/test-throttle.js new file mode 100644 index 00000000..c12fd8f3 --- /dev/null +++ b/api/tests/test-throttle.js @@ -0,0 +1,84 @@ +"use strict" + +var o = require("../../ospec/ospec") +var callAsync = require("../../test-utils/callAsync") +var throttle = require("../../api/throttle") + +o.spec("throttle", function() { + var FRAME_BUDGET = 1000 / 60 + var spy, throttled + o.beforeEach(function() { + spy = o.spy() + throttled = throttle(spy) + }) + + o("runs first call synchronously", function() { + throttled() + + o(spy.callCount).equals(1) + }) + + o("throttles subsequent synchronous calls", function(done) { + throttled() + throttled() + + o(spy.callCount).equals(1) + + setTimeout(function() { + o(spy.callCount).equals(2) + + done() + }, FRAME_BUDGET) //this delay is much higher than 16.6ms due to setTimeout clamp and other runtime costs + }) + + o("calls after threshold", function(done) { + throttled() + + o(spy.callCount).equals(1) + + setTimeout(function(t) { + throttled() + + o(spy.callCount).equals(2) + + done() + }, FRAME_BUDGET) + + }) + + o("throttles before threshold", function(done) { + throttled() + + o(spy.callCount).equals(1) + + callAsync(function(t) { + throttled() + + o(spy.callCount).equals(1) + + done() + }) + }) + + o("it only runs once per tick", function(done) { + throttled() + throttled() + throttled() + + o(spy.callCount).equals(1) + + setTimeout(function() { + o(spy.callCount).equals(2) + + done() + }, FRAME_BUDGET) + }) + + o("it supports forcing a synchronous redraw", function() { + throttled() + throttled() + throttled(true) + + o(spy.callCount).equals(2) + }) +}) \ No newline at end of file diff --git a/api/throttle.js b/api/throttle.js new file mode 100644 index 00000000..a1e18328 --- /dev/null +++ b/api/throttle.js @@ -0,0 +1,23 @@ +"use strict" + +module.exports = function(callback) { + //60fps translates to 16.6ms, round it down since setTimeout requires int + var time = 16 + var last = 0, pending = null + var timeout = typeof requestAnimationFrame === "function" ? requestAnimationFrame : setTimeout + return function(synchronous) { + var now = new Date().getTime() + var diff = now - last + if (synchronous === true || last === 0 || now - last >= time) { + last = now + callback() + } + else if (pending === null) { + pending = timeout(function() { + pending = 0 + callback() + last = new Date().getTime() + }, time - (now - last)) + } + } +} diff --git a/index.js b/index.js index deaf3d1f..3fd438ac 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ var m = require("./render/hyperscript") var trust = require("./render/trust") +var createRenderer = require("./render/render") var createMounter = require("./api/mount") var createRouterInstance = require("./api/router") var createRequester = require("./request/request") @@ -11,6 +12,7 @@ m.redraw = function() { redraw.run() } m.trust = trust +m.render = createRenderer(window).render m.mount = createMounter(window, redraw) m.route = createRouterInstance(window, redraw) m.request = createRequester(window, Promise).ajax diff --git a/mithril.js b/mithril.js index b1338d35..c82f1fdf 100644 --- a/mithril.js +++ b/mithril.js @@ -22,7 +22,7 @@ var selectorCache = {} function hyperscript(selector) { if (typeof selector === "string") { if (selectorCache[selector] === undefined) { - var match, tag, id, classes = [], attributes = {} + var match, tag, classes = [], attributes = {} while (match = selectorParser.exec(selector)) { var type = match[1], value = match[2] if (type === "" && value !== "") tag = value @@ -92,6 +92,7 @@ function changeNS(ns, vnode) { var m = hyperscript + var trust = function(html) { return Node("<", undefined, undefined, html, undefined, undefined) } @@ -414,7 +415,7 @@ var createRenderer = function($window) { } } if (vnode.dom.parentNode != null) parent.removeChild(vnode.dom) - if (context != null && vnode.domSize == null) { //TODO test custom elements + if (context != null && vnode.domSize == null && !hasIntegrationMethods(vnode)) { //TODO test custom elements if (!context.pool) context.pool = [vnode] else context.pool.push(vnode) } @@ -440,16 +441,19 @@ var createRenderer = function($window) { } } function setAttr(vnode, key, old, value) { - //TODO test input undo history var element = vnode.dom - if (key === "key" || (!isFormAttribute(vnode, key) && old === value) || typeof value === "undefined" || isLifecycleMethod(key)) return + if (key === "key" || (old === value && !isFormAttribute(vnode, key)) || typeof value === "undefined" || isLifecycleMethod(key)) return var nsLastIndex = key.indexOf(":") if (nsLastIndex > -1 && key.substr(0, nsLastIndex) === "xlink") { element.setAttributeNS("http://www.w3.org/1999/xlink", key.slice(nsLastIndex + 1), value) } else if (key[0] === "o" && key[1] === "n" && typeof value === "function") updateEvent(vnode, key, value) else if (key === "style") updateStyle(element, old, value) - else if (key in element && !isAttribute(key) && vnode.ns === undefined) element[key] = value + else if (key in element && !isAttribute(key) && vnode.ns === undefined) { + //setting input[value] to same value by typing on focused element moves cursor to end in Chrome + if (vnode.tag === "input" && key === "value" && vnode.dom.value === value && vnode.dom === $doc.activeElement) return + element[key] = value + } else { if (typeof value === "boolean") { if (value) element.setAttribute(key, "") @@ -488,6 +492,9 @@ var createRenderer = function($window) { function isAttribute(attr) { return attr === "href" || attr === "list" || attr === "form"// || attr === "type" || attr === "width" || attr === "height" } + function hasIntegrationMethods(vnode) { + return vnode.attrs != null && (vnode.attrs.oncreate || vnode.attrs.onupdate || vnode.attrs.onbeforeremove || vnode.attrs.onremove) + } //style function updateStyle(element, old, style) { @@ -575,54 +582,526 @@ var createRenderer = function($window) { return {render: render, setEventCallback: setEventCallback} } -var FRAME_BUDGET = 16 // 60 frames per second = 1 call per 16 ms -var limiter = function($window, render) { - var rAF = $window.requestAnimationFrame || $window.setTimeout - var cAF = $window.cancelAnimationFrame || $window.clearTimeout + +var createRenderer = function($window) { + var $doc = $window.document + + var onevent + function setEventCallback(callback) {return onevent = callback} - var last = 0 - var pending - - return function() { - var now = new Date() + //create + function createNodes(parent, vnodes, start, end, hooks, nextSibling) { + for (var i = start; i < end; i++) { + var vnode = vnodes[i] + if (vnode != null) { + insertNode(parent, createNode(vnode, hooks), nextSibling) + } + } + } + function createNode(vnode, hooks) { + var tag = vnode.tag + if (vnode.attrs != null) initLifecycle(vnode.attrs, vnode, hooks) + if (typeof tag === "string") { + switch (tag) { + case "#": return createText(vnode) + case "<": return createHTML(vnode) + case "[": return createFragment(vnode, hooks) + default: return createElement(vnode, hooks) + } + } + else return createComponent(vnode, hooks) + } + function createText(vnode) { + return vnode.dom = $doc.createTextNode(vnode.children) + } + function createHTML(vnode) { + var match = vnode.children.match(/^\s*?<(\w+)/im) || [] + var parent = {caption: "table", thead: "table", tbody: "table", tfoot: "table", tr: "tbody", th: "tr", td: "tr", colgroup: "table", col: "colgroup"}[match[1]] || "div" + var temp = $doc.createElement(parent) - // First render, OR if the time since the last render is greater - // than the frame budget - // just immediately render - if(!last || now - last > FRAME_BUDGET) { - last = now; - - return render() + temp.innerHTML = vnode.children + vnode.dom = temp.firstChild + vnode.domSize = temp.childNodes.length + var fragment = $doc.createDocumentFragment() + var child + while (child = temp.firstChild) { + fragment.appendChild(child) + } + return fragment + } + function createFragment(vnode, hooks) { + var fragment = $doc.createDocumentFragment() + if (vnode.children != null) { + var children = vnode.children + createNodes(fragment, children, 0, children.length, hooks, null) + } + vnode.dom = fragment.firstChild + vnode.domSize = fragment.childNodes.length + return fragment + } + function createElement(vnode, hooks) { + var tag = vnode.tag + var ns = vnode.ns + + var attrs = vnode.attrs + var is = attrs && attrs.is + + var element = ns ? + is ? $doc.createElementNS(ns, tag, is) : $doc.createElementNS(ns, tag) : + is ? $doc.createElement(tag, is) : $doc.createElement(tag) + vnode.dom = element + + if (attrs != null) { + setAttrs(vnode, attrs) } - // Redraw already pending, abort - if(pending) { - return + if (vnode.text != null) { + if (vnode.text !== "") element.textContent = vnode.text + else vnode.children = [Node("#", undefined, undefined, vnode.text, undefined, undefined)] } - // Schedule a redraw for the next tick - pending = rAF(function() { - render() + if (vnode.children != null) { + var children = vnode.children + createNodes(element, children, 0, children.length, hooks, null) + setLateAttrs(vnode) + } + return element + } + function createComponent(vnode, hooks) { + vnode.state = copy(vnode.tag) + + initLifecycle(vnode.tag, vnode, hooks) + vnode.instance = Node.normalize(vnode.tag.view.call(vnode.state, vnode)) + var element = createNode(vnode.instance, hooks) + vnode.dom = vnode.instance.dom + vnode.domSize = vnode.instance.domSize + return element + } + + //update + function updateNodes(parent, old, vnodes, hooks, nextSibling) { + if (old == null && vnodes == null) return + else if (old == null) createNodes(parent, vnodes, 0, vnodes.length, hooks, nextSibling) + else if (vnodes == null) removeNodes(parent, old, 0, old.length, vnodes) + else { + var recycling = isRecyclable(old, vnodes) + if (recycling) old = old.concat(old.pool) - last = new Date() - pending = null - }, FRAME_BUDGET - (now - last)) + var oldStart = 0, start = 0, oldEnd = old.length - 1, end = vnodes.length - 1, map + while (oldEnd >= oldStart && end >= start) { + var o = old[oldStart], v = vnodes[start] + if (o === v) oldStart++, start++ + else if (o != null && v != null && o.key === v.key) { + oldStart++, start++ + updateNode(parent, o, v, hooks, getNextSibling(old, oldStart, nextSibling), recycling) + if (recycling) insertNode(parent, toFragment(o), nextSibling) + } + else { + var o = old[oldEnd] + if (o === v) oldEnd--, start++ + else if (o != null && v != null && o.key === v.key) { + updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling) + insertNode(parent, toFragment(o), getNextSibling(old, oldStart, nextSibling)) + oldEnd--, start++ + } + else break + } + } + while (oldEnd >= oldStart && end >= start) { + var o = old[oldEnd], v = vnodes[end] + if (o === v) oldEnd--, end-- + else if (o != null && v != null && o.key === v.key) { + updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling) + if (recycling) insertNode(parent, toFragment(o), nextSibling) + nextSibling = o.dom + oldEnd--, end-- + } + else { + if (!map) map = getKeyMap(old, oldEnd) + if (v != null) { + var oldIndex = map[v.key] + if (oldIndex != null) { + var movable = old[oldIndex] + updateNode(parent, movable, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling) + insertNode(parent, toFragment(movable), nextSibling) + old[oldIndex].skip = true + nextSibling = movable.dom + } + else { + var dom = createNode(v, hooks) + insertNode(parent, dom, nextSibling) + nextSibling = dom + } + } + end-- + } + if (end < start) break + } + createNodes(parent, vnodes, start, end + 1, hooks, nextSibling) + removeNodes(parent, old, oldStart, oldEnd + 1, vnodes) + } + } + function updateNode(parent, old, vnode, hooks, nextSibling, recycling) { + var oldTag = old.tag, tag = vnode.tag + if (oldTag === tag) { + vnode.state = old.state + vnode.events = old.events + if (shouldUpdate(vnode, old)) return + if (vnode.attrs != null) { + updateLifecycle(vnode.attrs, vnode, hooks, recycling) + } + if (typeof oldTag === "string") { + switch (oldTag) { + case "#": updateText(old, vnode); break + case "<": updateHTML(parent, old, vnode, nextSibling); break + case "[": updateFragment(parent, old, vnode, hooks, nextSibling); break + default: updateElement(old, vnode, hooks) + } + } + else updateComponent(parent, old, vnode, hooks, nextSibling, recycling) + } + else { + removeNode(parent, old, null, false) + insertNode(parent, createNode(vnode, hooks), nextSibling) + } + } + function updateText(old, vnode) { + if (old.children.toString() !== vnode.children.toString()) { + old.dom.nodeValue = vnode.children + } + vnode.dom = old.dom + } + function updateHTML(parent, old, vnode, nextSibling) { + if (old.children !== vnode.children) { + toFragment(old) + insertNode(parent, createHTML(vnode), nextSibling) + } + else vnode.dom = old.dom + } + function updateFragment(parent, old, vnode, hooks, nextSibling) { + updateNodes(parent, old.children, vnode.children, hooks, nextSibling) + var domSize = 0, children = vnode.children + vnode.dom = null + if (children != null) { + for (var i = 0; i < children.length; i++) { + var child = children[i] + if (child != null) { + if (vnode.dom == null) vnode.dom = child.dom + domSize += child.domSize || 1 + } + } + if (domSize !== 1) vnode.domSize = domSize + } + } + function updateElement(old, vnode, hooks) { + var element = vnode.dom = old.dom + if (vnode.tag === "textarea") { + if (vnode.attrs == null) vnode.attrs = {} + if (vnode.text != null) vnode.attrs.value = vnode.text //FIXME handle multiple children + } + updateAttrs(vnode, old.attrs, vnode.attrs) + if (old.text != null && vnode.text != null && vnode.text !== "") { + if (old.text.toString() !== vnode.text.toString()) old.dom.firstChild.nodeValue = vnode.text + } + else { + if (old.text != null) old.children = [Node("#", undefined, undefined, old.text, undefined, old.dom.firstChild)] + if (vnode.text != null) vnode.children = [Node("#", undefined, undefined, vnode.text, undefined, undefined)] + updateNodes(element, old.children, vnode.children, hooks, null) + } + } + function updateComponent(parent, old, vnode, hooks, nextSibling, recycling) { + vnode.instance = Node.normalize(vnode.tag.view.call(vnode.state, vnode)) + updateLifecycle(vnode.tag, vnode, hooks, recycling) + updateNode(parent, old.instance, vnode.instance, hooks, nextSibling, recycling) + vnode.dom = vnode.instance.dom + vnode.domSize = vnode.instance.domSize + } + function isRecyclable(old, vnodes) { + if (old.pool != null && Math.abs(old.pool.length - vnodes.length) <= Math.abs(old.length - vnodes.length)) { + var oldChildrenLength = old[0] && old[0].children && old[0].children.length || 0 + var poolChildrenLength = old.pool[0] && old.pool[0].children && old.pool[0].children.length || 0 + var vnodesChildrenLength = vnodes[0] && vnodes[0].children && vnodes[0].children.length || 0 + if (Math.abs(poolChildrenLength - vnodesChildrenLength) <= Math.abs(oldChildrenLength - vnodesChildrenLength)) { + return true + } + } + return false + } + function getKeyMap(vnodes, end) { + var map = {}, i = 0 + for (var i = 0; i < end; i++) { + var vnode = vnodes[i] + if (vnode != null) { + var key = vnode.key + if (key != null) map[key] = i + } + } + return map + } + function toFragment(vnode) { + var count = vnode.domSize + if (count != null) { + var fragment = $doc.createDocumentFragment() + if (count > 0) { + var dom = vnode.dom + while (--count) fragment.appendChild(dom.nextSibling) + fragment.insertBefore(dom, fragment.firstChild) + } + return fragment + } + else return vnode.dom + } + function getNextSibling(vnodes, i, nextSibling) { + for (; i < vnodes.length; i++) { + if (vnodes[i] != null) return vnodes[i].dom + } + return nextSibling + } + + function insertNode(parent, dom, nextSibling) { + if (nextSibling && nextSibling.parentNode) parent.insertBefore(dom, nextSibling) + else parent.appendChild(dom) + } + + //remove + function removeNodes(parent, vnodes, start, end, context) { + for (var i = start; i < end; i++) { + var vnode = vnodes[i] + if (vnode != null) { + if (vnode.skip) vnode.skip = undefined + else removeNode(parent, vnode, context, false) + } + } + } + function removeNode(parent, vnode, context, deferred) { + if (deferred === false) { + var expected = 0, called = 0 + var callback = function() { + if (++called === expected) removeNode(parent, vnode, context, true) + } + if (vnode.attrs && vnode.attrs.onbeforeremove) { + expected++ + vnode.attrs.onbeforeremove.call(vnode, vnode, callback) + } + if (typeof vnode.tag !== "string" && vnode.tag.onbeforeremove) { + expected++ + vnode.tag.onbeforeremove.call(vnode, vnode, callback) + } + if (expected > 0) return + } + + onremove(vnode) + if (vnode.dom) { + var count = vnode.domSize || 1 + if (count > 1) { + var dom = vnode.dom + while (--count) { + parent.removeChild(dom.nextSibling) + } + } + if (vnode.dom.parentNode != null) parent.removeChild(vnode.dom) + if (context != null && vnode.domSize == null && !hasIntegrationMethods(vnode)) { //TODO test custom elements + if (!context.pool) context.pool = [vnode] + else context.pool.push(vnode) + } + } + } + function onremove(vnode) { + if (vnode.attrs && vnode.attrs.onremove) vnode.attrs.onremove.call(vnode.state, vnode) + if (typeof vnode.tag !== "string" && vnode.tag.onremove) vnode.tag.onremove.call(vnode.state, vnode) + + var children = vnode.children + if (children instanceof Array) { + for (var i = 0; i < children.length; i++) { + var child = children[i] + if (child != null) onremove(child) + } + } + } + + //attrs + function setAttrs(vnode, attrs) { + for (var key in attrs) { + setAttr(vnode, key, null, attrs[key]) + } + } + function setAttr(vnode, key, old, value) { + var element = vnode.dom + if (key === "key" || (old === value && !isFormAttribute(vnode, key)) || typeof value === "undefined" || isLifecycleMethod(key)) return + var nsLastIndex = key.indexOf(":") + if (nsLastIndex > -1 && key.substr(0, nsLastIndex) === "xlink") { + element.setAttributeNS("http://www.w3.org/1999/xlink", key.slice(nsLastIndex + 1), value) + } + else if (key[0] === "o" && key[1] === "n" && typeof value === "function") updateEvent(vnode, key, value) + else if (key === "style") updateStyle(element, old, value) + else if (key in element && !isAttribute(key) && vnode.ns === undefined) { + //setting input[value] to same value by typing on focused element moves cursor to end in Chrome + if (vnode.tag === "input" && key === "value" && vnode.dom.value === value && vnode.dom === $doc.activeElement) return + element[key] = value + } + else { + if (typeof value === "boolean") { + if (value) element.setAttribute(key, "") + else element.removeAttribute(key) + } + else element.setAttribute(key === "className" ? "class" : key, value) + } + } + function setLateAttrs(vnode) { + var attrs = vnode.attrs + if (vnode.tag === "select" && attrs != null) { + if ("value" in attrs) setAttr(vnode, "value", null, attrs.value) + if ("selectedIndex" in attrs) setAttr(vnode, "selectedIndex", null, attrs.selectedIndex) + } + } + function updateAttrs(vnode, old, attrs) { + if (attrs != null) { + for (var key in attrs) { + setAttr(vnode, key, old && old[key], attrs[key]) + } + } + if (old != null) { + for (var key in old) { + if (attrs == null || !(key in attrs)) { + if (key !== "key") vnode.dom.removeAttribute(key) + } + } + } + } + function isFormAttribute(vnode, attr) { + return attr === "value" || attr === "checked" || attr === "selectedIndex" || attr === "selected" && vnode.dom === $doc.activeElement + } + function isLifecycleMethod(attr) { + return attr === "oninit" || attr === "oncreate" || attr === "onupdate" || attr === "onremove" || attr === "onbeforeremove" || attr === "shouldUpdate" + } + function isAttribute(attr) { + return attr === "href" || attr === "list" || attr === "form"// || attr === "type" || attr === "width" || attr === "height" + } + function hasIntegrationMethods(vnode) { + return vnode.attrs != null && (vnode.attrs.oncreate || vnode.attrs.onupdate || vnode.attrs.onbeforeremove || vnode.attrs.onremove) + } + + //style + function updateStyle(element, old, style) { + if (style == null) element.style = "" + else if (typeof style === "string") element.style = style + else { + if (typeof old === "string") element.style = "" + for (var key in style) { + element.style[key] = style[key] + } + if (old != null && typeof old !== "string") { + for (var key in old) { + if (!(key in style)) element.style[key] = "" + } + } + } + } + + //event + function updateEvent(vnode, key, value) { + var element = vnode.dom + var callback = function(e) { + var result = value.call(element, e) + if (typeof onevent === "function") onevent.call(element, e) + return result + } + if (key in element) element[key] = callback + else { + var eventName = key.slice(2) + if (vnode.events === undefined) vnode.events = {} + if (vnode.events[key] != null) element.removeEventListener(eventName, vnode.events[key], false) + vnode.events[key] = callback + element.addEventListener(eventName, vnode.events[key], false) + } + } + + //lifecycle + function initLifecycle(source, vnode, hooks) { + if (source.oninit != null) source.oninit.call(vnode.state, vnode) + if (source.oncreate != null) hooks.push(source.oncreate.bind(vnode.state, vnode)) + } + function updateLifecycle(source, vnode, hooks, recycling) { + if (recycling) initLifecycle(source, vnode, hooks) + else if (source.onupdate != null) hooks.push(source.onupdate.bind(vnode.state, vnode)) + } + function shouldUpdate(vnode, old) { + var forceVnodeUpdate, forceComponentUpdate + if (vnode.attrs != null && typeof vnode.attrs.shouldUpdate === "function") forceVnodeUpdate = vnode.attrs.shouldUpdate.call(vnode.state, vnode, old) + if (typeof vnode.tag !== "string" && typeof vnode.tag.shouldUpdate === "function") forceComponentUpdate = vnode.tag.shouldUpdate.call(vnode.state, vnode, old) + if (!(forceVnodeUpdate === undefined && forceComponentUpdate === undefined) && !forceVnodeUpdate && !forceComponentUpdate) { + vnode.dom = old.dom + vnode.domSize = old.domSize + vnode.instance = old.instance + return true + } + return false + } + + function copy(data) { + if (data instanceof Array) { + var output = [] + for (var i = 0; i < data.length; i++) output[i] = copy(data[i]) + return output + } + else if (typeof data === "object") { + var output = {} + for (var i in data) output[i] = copy(data[i]) + return output + } + return data + } + + function render(dom, vnodes) { + var hooks = [] + var active = $doc.activeElement + if (dom.vnodes == null) dom.vnodes = [] + + if (!(vnodes instanceof Array)) vnodes = [vnodes] + updateNodes(dom, dom.vnodes, Node.normalizeChildren(vnodes), hooks, null) + for (var i = 0; i < hooks.length; i++) hooks[i]() + dom.vnodes = vnodes + if ($doc.activeElement !== active) active.focus() + } + + return {render: render, setEventCallback: setEventCallback} +} + +var throttle = function(callback) { + //60fps translates to 16.6ms, round it down since setTimeout requires int + var time = 16 + var last = 0, pending = null + var timeout = typeof requestAnimationFrame === "function" ? requestAnimationFrame : setTimeout + return function(synchronous) { + var now = new Date().getTime() + var diff = now - last + if (synchronous === true || last === 0 || now - last >= time) { + last = now + callback() + } + else if (pending === null) { + pending = timeout(function() { + pending = 0 + callback() + last = new Date().getTime() + }, time - (now - last)) + } } } -; + var createMounter = function($window, redraw) { + var renderer = createRenderer($window) return function(root, component) { - var renderer = createRenderer($window) - var draw = limiter($window, function draw() { + var run = throttle(function() { renderer.render(root, {tag: component}) }) - renderer.setEventCallback(draw) + renderer.setEventCallback(run) - redraw.run = draw - draw() + redraw.run = run + run() } } @@ -800,14 +1279,15 @@ var createRouterInstance = function($window, redraw) { var renderer = createRenderer($window) var router = createRouter($window) var route = function(root, defaultRoute, routes) { - var replay = limiter($window, router.defineRoutes(routes, function(component, args) { + var replay = router.defineRoutes(routes, function(component, args) { renderer.render(root, {tag: component, attrs: args}) }, function() { router.setPath(defaultRoute) - })) + }) + var run = throttle(replay) - renderer.setEventCallback(replay) - redraw.run = replay + renderer.setEventCallback(run) + redraw.run = run } route.link = router.link route.prefix = router.setPrefix @@ -933,6 +1413,7 @@ m.redraw = function() { redraw.run() } m.trust = trust +m.render = createRenderer(window).render m.mount = createMounter(window, redraw) m.route = createRouterInstance(window, redraw) m.request = createRequester(window, Promise).ajax diff --git a/render/render.js b/render/render.js index 56888a64..1f22fff6 100644 --- a/render/render.js +++ b/render/render.js @@ -345,7 +345,6 @@ module.exports = function($window) { } } function setAttr(vnode, key, old, value) { - //TODO test input undo history var element = vnode.dom if (key === "key" || (old === value && !isFormAttribute(vnode, key)) || typeof value === "undefined" || isLifecycleMethod(key)) return var nsLastIndex = key.indexOf(":") @@ -355,6 +354,7 @@ module.exports = function($window) { else if (key[0] === "o" && key[1] === "n" && typeof value === "function") updateEvent(vnode, key, value) else if (key === "style") updateStyle(element, old, value) else if (key in element && !isAttribute(key) && vnode.ns === undefined) { + //setting input[value] to same value by typing on focused element moves cursor to end in Chrome if (vnode.tag === "input" && key === "value" && vnode.dom.value === value && vnode.dom === $doc.activeElement) return element[key] = value } diff --git a/render/tests/test-event.js b/render/tests/test-event.js index 8263d277..c6e9407f 100644 --- a/render/tests/test-event.js +++ b/render/tests/test-event.js @@ -60,7 +60,7 @@ o.spec("event", function() { o("handles ontransitionend", function() { var spy = o.spy() var div = {tag: "div", attrs: {ontransitionend: spy}} - var e = $window.document.createEvent("AnimationEvent") + var e = $window.document.createEvent("HTMLEvents") e.initEvent("transitionend", true, true) render(root, [div]) diff --git a/render/tests/test-style.js b/render/tests/test-style.js deleted file mode 100644 index e69de29b..00000000 diff --git a/test-input.html b/test-input.html deleted file mode 100644 index a893fccb..00000000 --- a/test-input.html +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - -
-

-		
-		
-		
-		
-		
-	
-
\ No newline at end of file
diff --git a/test-utils/tests/test-domMock.js b/test-utils/tests/test-domMock.js
index a9aa4b6d..b7dac365 100644
--- a/test-utils/tests/test-domMock.js
+++ b/test-utils/tests/test-domMock.js
@@ -533,7 +533,7 @@ o.spec("domMock", function() {
 			o.beforeEach(function() {
 				spy = o.spy()
 				div = $document.createElement("div")
-				e = $document.createEvent("AnimationEvent")
+				e = $document.createEvent("HTMLEvents")
 				e.initEvent("transitionend", true, true)
 				
 				$document.body.appendChild(div)