Merge pull request #1592 from pygy/async-redraw

Make m.redraw() purely asynchronous, add m.redraw.sync()
This commit is contained in:
Pierre-Yves Gérardy 2017-07-17 23:16:44 +02:00 committed by GitHub
commit 8ab31790ab
11 changed files with 381 additions and 116 deletions

View file

@ -16,6 +16,6 @@ module.exports = function(redrawService) {
redrawService.render(root, Vnode(component))
}
redrawService.subscribe(root, run)
redrawService.redraw()
run()
}
}

View file

@ -4,26 +4,23 @@ var coreRenderer = require("../render/render")
function throttle(callback) {
//60fps translates to 16.6ms, round it down since setTimeout requires int
var time = 16
var delay = 16
var last = 0, pending = null
var timeout = typeof requestAnimationFrame === "function" ? requestAnimationFrame : setTimeout
return function() {
var now = Date.now()
if (last === 0 || now - last >= time) {
last = now
callback()
}
else if (pending === null) {
var elapsed = Date.now() - last
if (pending === null) {
pending = timeout(function() {
pending = null
callback()
last = Date.now()
}, time - (now - last))
}, delay - elapsed)
}
}
}
module.exports = function($window) {
module.exports = function($window, throttleMock) {
var renderService = coreRenderer($window)
renderService.setEventCallback(function(e) {
if (e.redraw === false) e.redraw = undefined
@ -31,18 +28,24 @@ module.exports = function($window) {
})
var callbacks = []
var rendering = false
function subscribe(key, callback) {
unsubscribe(key)
callbacks.push(key, throttle(callback))
callbacks.push(key, callback)
}
function unsubscribe(key) {
var index = callbacks.indexOf(key)
if (index > -1) callbacks.splice(index, 2)
}
function redraw() {
for (var i = 1; i < callbacks.length; i += 2) {
callbacks[i]()
}
function sync() {
if (rendering) throw new Error("Nested m.redraw.sync() call")
rendering = true
for (var i = 1; i < callbacks.length; i+=2) try {callbacks[i]()} catch (e) {/*noop*/}
rendering = false
}
var redraw = (throttleMock || throttle)(sync)
redraw.sync = sync
return {subscribe: subscribe, unsubscribe: unsubscribe, redraw: redraw, render: renderService.render}
}

View file

@ -11,9 +11,14 @@ module.exports = function($window, redrawService) {
var render, component, attrs, currentPath, lastUpdate
var route = function(root, defaultRoute, routes) {
if (root == null) throw new Error("Ensure the DOM element that was passed to `m.route` is not undefined")
var run = function() {
function run() {
if (render != null) redrawService.render(root, render(Vnode(component, attrs.key, attrs)))
}
var redraw = function() {
run()
redraw = redrawService.redraw
}
redrawService.subscribe(root, run)
var bail = function(path) {
if (path !== defaultRoute) routeService.setPath(defaultRoute, null, {replace: true})
else throw new Error("Could not resolve default route " + defaultRoute)
@ -24,7 +29,7 @@ module.exports = function($window, redrawService) {
component = comp != null && (typeof comp.view === "function" || typeof comp === "function")? comp : "div"
attrs = params, currentPath = path, lastUpdate = null
render = (routeResolver.render || identity).bind(routeResolver)
run()
redraw()
}
if (payload.view || typeof payload === "function") update({}, payload)
else {
@ -36,7 +41,6 @@ module.exports = function($window, redrawService) {
else update(payload, "div")
}
}, bail)
redrawService.subscribe(root, run)
}
route.set = function(path, data, options) {
if (lastUpdate != null) {

View file

@ -3,25 +3,29 @@
var o = require("../../ospec/ospec")
var components = require("../../test-utils/components")
var domMock = require("../../test-utils/domMock")
var throttleMocker = require("../../test-utils/throttleMock")
var m = require("../../render/hyperscript")
var apiRedraw = require("../../api/redraw")
var apiMounter = require("../../api/mount")
o.spec("mount", function() {
var FRAME_BUDGET = Math.floor(1000 / 60)
var $window, root, redrawService, mount, render
var $window, root, redrawService, mount, render, throttleMock
o.beforeEach(function() {
$window = domMock()
throttleMock = throttleMocker()
root = $window.document.body
redrawService = apiRedraw($window)
redrawService = apiRedraw($window, throttleMock.throttle)
mount = apiMounter(redrawService)
render = redrawService.render
})
o.afterEach(function() {
o(throttleMock.queueLength()).equals(0)
})
o("throws on invalid component", function() {
var threw = false
try {
@ -46,7 +50,7 @@ o.spec("mount", function() {
o(threw).equals(true)
})
o("renders into `root`", function() {
o("renders into `root` synchronoulsy", function() {
mount(root, createComponent({
view : function() {
return m("div")
@ -68,7 +72,37 @@ o.spec("mount", function() {
o(root.childNodes.length).equals(0)
})
o("redraws on events", function(done) {
o("Mounting a second root doesn't cause the first one to redraw", function() {
var view = o.spy(function() {
return m("div")
})
render(root, [
m("#child0"),
m("#child1")
])
mount(root.childNodes[0], createComponent({
view : view
}))
o(root.firstChild.nodeName).equals("DIV")
o(view.callCount).equals(1)
mount(root.childNodes[1], createComponent({
view : function() {
return m("div")
}
}))
o(view.callCount).equals(1)
throttleMock.fire()
o(view.callCount).equals(1)
})
o("redraws on events", function() {
var onupdate = o.spy()
var oninit = o.spy()
var onclick = o.spy()
@ -96,17 +130,12 @@ o.spec("mount", function() {
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)
throttleMock.fire()
done()
}, FRAME_BUDGET)
o(onupdate.callCount).equals(1)
})
o("redraws several mount points on events", function(done, timeout) {
timeout(60)
o("redraws several mount points on events", function() {
var onupdate0 = o.spy()
var oninit0 = o.spy()
var onclick0 = o.spy()
@ -153,26 +182,26 @@ o.spec("mount", function() {
o(onclick0.callCount).equals(1)
o(onclick0.this).equals(root.childNodes[0].firstChild)
setTimeout(function() {
o(onupdate0.callCount).equals(1)
o(onupdate1.callCount).equals(1)
throttleMock.fire()
root.childNodes[1].firstChild.dispatchEvent(e)
o(onclick1.callCount).equals(1)
o(onclick1.this).equals(root.childNodes[1].firstChild)
o(onupdate0.callCount).equals(1)
o(onupdate1.callCount).equals(1)
setTimeout(function() {
o(onupdate0.callCount).equals(2)
o(onupdate1.callCount).equals(2)
root.childNodes[1].firstChild.dispatchEvent(e)
done()
}, FRAME_BUDGET)
}, FRAME_BUDGET)
o(onclick1.callCount).equals(1)
o(onclick1.this).equals(root.childNodes[1].firstChild)
throttleMock.fire()
o(onupdate0.callCount).equals(2)
o(onupdate1.callCount).equals(2)
})
o("event handlers can skip redraw", function(done) {
var onupdate = o.spy()
o("event handlers can skip redraw", function() {
var onupdate = o.spy(function(){
throw new Error("This shouldn't have been called")
})
var oninit = o.spy()
var e = $window.document.createEvent("MouseEvents")
@ -194,15 +223,12 @@ o.spec("mount", function() {
o(oninit.callCount).equals(1)
// Wrapped to ensure no redraw fired
setTimeout(function() {
o(onupdate.callCount).equals(0)
throttleMock.fire()
done()
}, FRAME_BUDGET)
o(onupdate.callCount).equals(0)
})
o("redraws when the render function is run", function(done) {
o("redraws when the render function is run", function() {
var onupdate = o.spy()
var oninit = o.spy()
@ -220,17 +246,12 @@ o.spec("mount", function() {
redrawService.redraw()
// Wrapped to give time for the rate-limited redraw to fire
setTimeout(function() {
o(onupdate.callCount).equals(1)
throttleMock.fire()
done()
}, FRAME_BUDGET)
o(onupdate.callCount).equals(1)
})
o("throttles", function(done, timeout) {
timeout(200)
o("throttles", function() {
var i = 0
mount(root, createComponent({view: function() {i++}}))
var before = i
@ -242,12 +263,11 @@ o.spec("mount", function() {
var after = i
setTimeout(function(){
o(before).equals(1) // mounts synchronously
o(after).equals(1) // throttles rest
o(i).equals(2)
done()
},40)
throttleMock.fire()
o(before).equals(1) // mounts synchronously
o(after).equals(1) // throttles rest
o(i).equals(2)
})
})
})

View file

@ -2,6 +2,7 @@
var o = require("../../ospec/ospec")
var domMock = require("../../test-utils/domMock")
var throttleMocker = require("../../test-utils/throttleMock")
var apiRedraw = require("../../api/redraw")
o.spec("redrawService", function() {
@ -17,25 +18,39 @@ o.spec("redrawService", function() {
redrawService.redraw()
})
o("honours throttleMock", function() {
var throttleMock = throttleMocker()
redrawService = apiRedraw(domMock(), throttleMock.throttle)
var spy = o.spy()
redrawService.subscribe(root, spy)
o(spy.callCount).equals(0)
redrawService.redraw()
o(spy.callCount).equals(0)
throttleMock.fire()
o(spy.callCount).equals(1)
})
o("should run a single renderer entry", function(done) {
var spy = o.spy()
redrawService.subscribe(root, spy)
o(spy.callCount).equals(0)
redrawService.redraw()
o(spy.callCount).equals(1)
redrawService.redraw()
redrawService.redraw()
redrawService.redraw()
o(spy.callCount).equals(1)
o(spy.callCount).equals(0)
setTimeout(function() {
o(spy.callCount).equals(2)
o(spy.callCount).equals(1)
done()
}, 20)
})
@ -54,27 +69,29 @@ o.spec("redrawService", function() {
redrawService.redraw()
o(spy1.callCount).equals(1)
o(spy2.callCount).equals(1)
o(spy3.callCount).equals(1)
o(spy1.callCount).equals(0)
o(spy2.callCount).equals(0)
o(spy3.callCount).equals(0)
redrawService.redraw()
o(spy1.callCount).equals(1)
o(spy2.callCount).equals(1)
o(spy3.callCount).equals(1)
o(spy1.callCount).equals(0)
o(spy2.callCount).equals(0)
o(spy3.callCount).equals(0)
setTimeout(function() {
o(spy1.callCount).equals(2)
o(spy2.callCount).equals(2)
o(spy3.callCount).equals(2)
o(spy1.callCount).equals(1)
o(spy2.callCount).equals(1)
o(spy3.callCount).equals(1)
done()
}, 20)
})
o("should stop running after unsubscribe", function() {
var spy = o.spy()
o("should stop running after unsubscribe", function(done) {
var spy = o.spy(function() {
throw new Error("This shouldn't have been called")
})
redrawService.subscribe(root, spy)
redrawService.unsubscribe(root, spy)
@ -82,9 +99,33 @@ o.spec("redrawService", function() {
redrawService.redraw()
o(spy.callCount).equals(0)
setTimeout(function() {
o(spy.callCount).equals(0)
done()
}, 20)
})
o("does nothing on invalid unsubscribe", function() {
o("should stop running after unsubscribe, even if it occurs after redraw is requested", function(done) {
var spy = o.spy(function() {
throw new Error("This shouldn't have been called")
})
redrawService.subscribe(root, spy)
redrawService.redraw()
redrawService.unsubscribe(root, spy)
o(spy.callCount).equals(0)
setTimeout(function() {
o(spy.callCount).equals(0)
done()
}, 20)
})
o("does nothing on invalid unsubscribe", function(done) {
var spy = o.spy()
redrawService.subscribe(root, spy)
@ -92,6 +133,39 @@ o.spec("redrawService", function() {
redrawService.redraw()
o(spy.callCount).equals(1)
setTimeout(function() {
o(spy.callCount).equals(1)
done()
}, 20)
})
o("redraw.sync() redraws all roots synchronously", function() {
var el1 = $document.createElement("div")
var el2 = $document.createElement("div")
var el3 = $document.createElement("div")
var spy1 = o.spy()
var spy2 = o.spy()
var spy3 = o.spy()
redrawService.subscribe(el1, spy1)
redrawService.subscribe(el2, spy2)
redrawService.subscribe(el3, spy3)
o(spy1.callCount).equals(0)
o(spy2.callCount).equals(0)
o(spy3.callCount).equals(0)
redrawService.redraw.sync()
o(spy1.callCount).equals(1)
o(spy2.callCount).equals(1)
o(spy3.callCount).equals(1)
redrawService.redraw.sync()
o(spy1.callCount).equals(2)
o(spy2.callCount).equals(2)
o(spy3.callCount).equals(2)
})
})

View file

@ -3,6 +3,7 @@
var o = require("../../ospec/ospec")
var callAsync = require("../../test-utils/callAsync")
var browserMock = require("../../test-utils/browserMock")
var throttleMocker = require("../../test-utils/throttleMock")
var m = require("../../render/hyperscript")
var callAsync = require("../../test-utils/callAsync")
@ -14,19 +15,23 @@ o.spec("route", function() {
void [{protocol: "http:", hostname: "localhost"}, {protocol: "file:", hostname: "/"}].forEach(function(env) {
void ["#", "?", "", "#!", "?!", "/foo"].forEach(function(prefix) {
o.spec("using prefix `" + prefix + "` starting on " + env.protocol + "//" + env.hostname, function() {
var FRAME_BUDGET = Math.floor(1000 / 60)
var $window, root, redrawService, route
var $window, root, redrawService, route, throttleMock
o.beforeEach(function() {
$window = browserMock(env)
throttleMock = throttleMocker()
root = $window.document.body
redrawService = apiRedraw($window)
redrawService = apiRedraw($window, throttleMock.throttle)
route = apiRouter($window, redrawService)
route.prefix(prefix)
})
o.afterEach(function() {
o(throttleMock.queueLength()).equals(0)
})
o("throws on invalid `root` DOM node", function() {
var threw = false
try {
@ -50,7 +55,7 @@ o.spec("route", function() {
o(root.firstChild.nodeName).equals("DIV")
})
o("routed mount points can redraw synchronously (POJO component)", function() {
o("routed mount points only redraw asynchronously (POJO component)", function() {
var view = o.spy()
$window.location.href = prefix + "/"
@ -60,11 +65,14 @@ o.spec("route", function() {
redrawService.redraw()
o(view.callCount).equals(2)
o(view.callCount).equals(1)
throttleMock.fire()
o(view.callCount).equals(2)
})
o("routed mount points can redraw synchronously (constructible component)", function() {
o("routed mount points only redraw asynchronously (constructible component)", function() {
var view = o.spy()
var Cmp = function(){}
@ -77,11 +85,14 @@ o.spec("route", function() {
redrawService.redraw()
o(view.callCount).equals(2)
o(view.callCount).equals(1)
throttleMock.fire()
o(view.callCount).equals(2)
})
o("routed mount points can redraw synchronously (closure component)", function() {
o("routed mount points only redraw asynchronously (closure component)", function() {
var view = o.spy()
function Cmp() {return {view: view}}
@ -93,8 +104,11 @@ o.spec("route", function() {
redrawService.redraw()
o(view.callCount).equals(2)
o(view.callCount).equals(1)
throttleMock.fire()
o(view.callCount).equals(2)
})
o("default route doesn't break back button", function(done) {
@ -160,11 +174,12 @@ o.spec("route", function() {
o(oninit.callCount).equals(1)
redrawService.redraw()
throttleMock.fire()
o(onupdate.callCount).equals(1)
})
o("redraws on events", function(done) {
o("redraws on events", function() {
var onupdate = o.spy()
var oninit = o.spy()
var onclick = o.spy()
@ -194,12 +209,9 @@ o.spec("route", function() {
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
callAsync(function() {
o(onupdate.callCount).equals(1)
done()
})
throttleMock.fire()
o(onupdate.callCount).equals(1)
})
o("event handlers can skip redraw", function(done) {
@ -502,7 +514,10 @@ o.spec("route", function() {
o(oninit.callCount).equals(1)
route.set("/def")
callAsync(function() {
throttleMock.fire()
o(oninit.callCount).equals(2)
done()
})
})
@ -538,23 +553,28 @@ o.spec("route", function() {
route(root, "/a", {
"/a" : {
render: function() {
return m("div")
return m("div", m("p"))
},
},
"/b" : {
render: function() {
return m("div")
return m("div", m("a"))
},
},
})
var dom = root.firstChild
var child = dom.firstChild
o(root.firstChild.nodeName).equals("DIV")
route.set("/b")
callAsync(function() {
throttleMock.fire()
o(root.firstChild).equals(dom)
o(root.firstChild.firstChild).notEquals(child)
done()
})
@ -588,6 +608,7 @@ o.spec("route", function() {
o(renderCount).equals(1)
redrawService.redraw()
throttleMock.fire()
o(matchCount).equals(1)
o(renderCount).equals(2)
@ -623,6 +644,7 @@ o.spec("route", function() {
o(renderCount).equals(1)
redrawService.redraw()
throttleMock.fire()
o(matchCount).equals(1)
o(renderCount).equals(2)
@ -818,10 +840,14 @@ o.spec("route", function() {
})
callAsync(function() {
throttleMock.fire()
route.set("/b")
callAsync(function() {
callAsync(function() {
callAsync(function() {
throttleMock.fire()
o(render.callCount).equals(0)
o(component.view.callCount).equals(2)
@ -942,6 +968,7 @@ o.spec("route", function() {
o(onmatch.callCount).equals(1)
redrawService.redraw()
throttleMock.fire()
o(view.callCount).equals(2)
o(onmatch.callCount).equals(1)
@ -1020,6 +1047,8 @@ o.spec("route", function() {
})
callAsync(function() {
throttleMock.fire()
o(onmatch.callCount).equals(1)
o(render.callCount).equals(1)
@ -1027,6 +1056,8 @@ o.spec("route", function() {
callAsync(function() {
callAsync(function() {
throttleMock.fire()
o(onmatch.callCount).equals(2)
o(render.callCount).equals(2)
@ -1077,9 +1108,15 @@ o.spec("route", function() {
route.set("/b")
callAsync(function() {
throttleMock.fire()
o(root.firstChild.nodeName).equals("B")
route.set("/a")
callAsync(function() {
throttleMock.fire()
o(root.firstChild.nodeName).equals("A")
done()
@ -1144,7 +1181,9 @@ o.spec("route", function() {
route.set("/b")
// setting the route is asynchronous
callAsync(function() {
throttleMock.fire()
o(spy.callCount).equals(1)
done()
@ -1184,9 +1223,7 @@ o.spec("route", function() {
})
})
o("throttles", function(done, timeout) {
timeout(200)
o("throttles", function() {
var i = 0
$window.location.href = prefix + "/"
route(root, "/", {
@ -1200,12 +1237,11 @@ o.spec("route", function() {
redrawService.redraw()
var after = i
setTimeout(function() {
o(before).equals(1) // routes synchronously
o(after).equals(2) // redraws synchronously
o(i).equals(3) // throttles rest
done()
}, FRAME_BUDGET * 2)
throttleMock.fire()
o(before).equals(1) // routes synchronously
o(after).equals(1) // redraws asynchronously
o(i).equals(2)
})
o("m.route.param is available outside of route handlers", function(done) {

View file

@ -14,7 +14,7 @@ You DON'T need to call it if data is modified within the execution context of an
You DO need to call it in `setTimeout`/`setInterval`/`requestAnimationFrame` callbacks, or callbacks from 3rd party libraries.
Typically, `m.redraw` triggers an asynchronous redraws, but it may trigger synchronously if Mithril detects it's possible to improve performance by doing so (i.e. if no redraw was requested within the last animation frame). You should write code assuming that it always redraws asynchronously.
`m.redraw` always triggers an asynchronous redraws.
---

View file

@ -65,7 +65,7 @@ Argument | Type | Required | D
##### m.route.set
Redirects to a matching route, or to the default route if no matching routes can be found.
Redirects to a matching route, or to the default route if no matching routes can be found. Triggers an asynchronous redraw off all mount points.
`m.route.set(path, data, options)`

View file

@ -0,0 +1,91 @@
"use strict"
var o = require("../../ospec/ospec")
var throttleMocker = require("../../test-utils/throttleMock")
o.spec("throttleMock", function() {
o("works with one callback", function() {
var throttleMock = throttleMocker()
var spy = o.spy()
o(throttleMock.queueLength()).equals(0)
var throttled = throttleMock.throttle(spy)
o(throttleMock.queueLength()).equals(0)
o(spy.callCount).equals(0)
throttled()
o(throttleMock.queueLength()).equals(1)
o(spy.callCount).equals(0)
throttled()
o(throttleMock.queueLength()).equals(1)
o(spy.callCount).equals(0)
throttleMock.fire()
o(throttleMock.queueLength()).equals(0)
o(spy.callCount).equals(1)
throttleMock.fire()
o(spy.callCount).equals(1)
})
o("works with two callbacks", function() {
var throttleMock = throttleMocker()
var spy1 = o.spy()
var spy2 = o.spy()
o(throttleMock.queueLength()).equals(0)
var throttled1 = throttleMock.throttle(spy1)
o(throttleMock.queueLength()).equals(0)
o(spy1.callCount).equals(0)
o(spy2.callCount).equals(0)
throttled1()
o(throttleMock.queueLength()).equals(1)
o(spy1.callCount).equals(0)
o(spy2.callCount).equals(0)
throttled1()
o(throttleMock.queueLength()).equals(1)
o(spy1.callCount).equals(0)
o(spy2.callCount).equals(0)
var throttled2 = throttleMock.throttle(spy2)
o(throttleMock.queueLength()).equals(1)
o(spy1.callCount).equals(0)
o(spy2.callCount).equals(0)
throttled2()
o(throttleMock.queueLength()).equals(2)
o(spy1.callCount).equals(0)
o(spy2.callCount).equals(0)
throttled2()
o(throttleMock.queueLength()).equals(2)
o(spy1.callCount).equals(0)
o(spy2.callCount).equals(0)
throttleMock.fire()
o(throttleMock.queueLength()).equals(0)
o(spy1.callCount).equals(1)
o(spy2.callCount).equals(1)
throttleMock.fire()
o(spy1.callCount).equals(1)
o(spy2.callCount).equals(1)
})
})

View file

@ -0,0 +1,27 @@
"use strict"
module.exports = function() {
var queue = []
return {
throttle: function(fn) {
var pending = false
return function() {
if (!pending) {
queue.push(function(){
pending = false
fn()
})
pending = true
}
}
},
fire: function() {
var tasks = queue
queue = []
tasks.forEach(function(fn) {fn()})
},
queueLength: function(){
return queue.length
}
}
}

View file

@ -163,14 +163,24 @@ o.spec("api", function() {
var count = 0
var root = window.document.createElement("div")
m.mount(root, createComponent({view: function() {count++}}))
o(count).equals(1)
m.redraw()
o(count).equals(1)
setTimeout(function() {
m.redraw()
o(count).equals(2)
done()
}, FRAME_BUDGET)
})
o("sync", function() {
var root = window.document.createElement("div")
var view = o.spy()
m.mount(root, createComponent({view: view}))
o(view.callCount).equals(1)
m.redraw.sync()
o(view.callCount).equals(2)
})
})
})
})