Merge pull request #1291 from pygy/debounceAsync

Router: Add debounceAsync to normalize the timing of onhashchange and onpopstate.
This commit is contained in:
Leo Horie 2016-09-07 09:51:15 -04:00 committed by GitHub
commit 6a7b048064
8 changed files with 353 additions and 258 deletions

View file

@ -2,38 +2,57 @@
var Vnode = require("../render/vnode")
var coreRouter = require("../router/router")
var autoredraw = require("../api/autoredraw")
module.exports = function($window, renderer, pubsub) {
module.exports = function($window, mount) {
var router = coreRouter($window)
var globalId, currentComponent, currentRender, currentArgs, currentPath
var RouteComponent = {view: function() {
return currentRender(Vnode(currentComponent, null, currentArgs, undefined, undefined, undefined))
}}
function defaultRender(vnode) {
return vnode
}
var route = function(root, defaultRoute, routes) {
var current = {path: null, component: "div", resolver: null}, currentResolutionIdentifier = null
var replay = router.defineRoutes(routes, function(payload, args, path, route) {
var resolutionIdentifier = currentResolutionIdentifier = {}
function resolve(component) {
if (resolutionIdentifier !== currentResolutionIdentifier) return
resolutionIdentifier = null
current.path = path, current.component = component
renderer.render(root, payload.render(Vnode(component, null, args, undefined, undefined, undefined)))
currentComponent = "div"
currentRender = defaultRender
currentArgs = null
mount(root, RouteComponent)
router.defineRoutes(routes, function(payload, args, path) {
var resolutionIdentifier = globalId = {}
var isResolver = typeof payload.view !== "function"
var render = defaultRender
function resolve (component) {
if (resolutionIdentifier !== globalId) return
globalId = null
currentComponent = component != null ? component : isResolver ? "div" : payload
currentRender = render
currentArgs = args
currentPath = path
root.redraw(true)
}
if (typeof payload.view !== "function") {
if (typeof payload.render !== "function") payload.render = function(vnode) {return vnode}
if (typeof payload.onmatch !== "function") payload.onmatch = function() {resolve(current.component)}
if (path !== current.path) payload.onmatch(Vnode(payload, null, args, undefined, undefined, undefined), resolve)
else resolve(current.component)
var onmatch = function() {
resolve()
}
else {
renderer.render(root, Vnode(payload, null, args, undefined, undefined, undefined))
if (isResolver) {
if (typeof payload.render === "function") render = payload.render.bind(payload)
if (typeof payload.onmatch === "function") onmatch = payload.onmatch
}
onmatch.call(payload, resolve, args, path)
}, function() {
router.setPath(defaultRoute, null, {replace: true})
})
autoredraw(root, renderer, pubsub, replay)
}
route.link = router.link
route.prefix = router.setPrefix
route.set = router.setPath
route.get = router.getPath
route.get = function() {return currentPath}
return route
}

View file

@ -8,13 +8,14 @@ var m = require("../../render/hyperscript")
var coreRenderer = require("../../render/render")
var apiPubSub = require("../../api/pubsub")
var apiRouter = require("../../api/router")
var apiMounter = require("../../api/mount")
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, redraw, route
var $window, root, redraw, mount, route
o.beforeEach(function() {
$window = browserMock(env)
@ -22,11 +23,12 @@ o.spec("route", function() {
root = $window.document.body
redraw = apiPubSub()
route = apiRouter($window, coreRenderer($window), redraw)
mount = apiMounter(coreRenderer($window), redraw)
route = apiRouter($window, mount)
route.prefix(prefix)
})
o("renders into `root`", function(done) {
o("renders into `root`", function() {
$window.location.href = prefix + "/"
route(root, "/", {
"/" : {
@ -36,11 +38,21 @@ o.spec("route", function() {
}
})
callAsync(function() {
o(root.firstChild.nodeName).equals("DIV")
done()
})
o(root.firstChild.nodeName).equals("DIV")
})
o("routed mount points can redraw synchronoulsy (#1275)", function() {
var view = o.spy()
$window.location.href = prefix + "/"
route(root, "/", {"/":{view:view}})
o(view.callCount).equals(1)
redraw.publish(true)
o(view.callCount).equals(2)
})
o("default route doesn't break back button", function(done) {
@ -55,11 +67,11 @@ o.spec("route", function() {
setTimeout(function() {
o(root.firstChild.nodeName).equals("DIV")
$window.history.back()
o($window.location.pathname).equals("/")
done()
}, FRAME_BUDGET)
})
@ -77,12 +89,12 @@ o.spec("route", function() {
function init(vnode) {
o(vnode.attrs.foo).equals(undefined)
done()
}
})
o("redraws when render function is executed", function(done) {
o("redraws when render function is executed", function() {
var onupdate = o.spy()
var oninit = o.spy()
@ -98,18 +110,11 @@ o.spec("route", function() {
}
})
callAsync(function() {
o(oninit.callCount).equals(1)
o(oninit.callCount).equals(1)
redraw.publish()
redraw.publish(true)
// Wrapped to give time for the rate-limited redraw to fire
setTimeout(function() {
o(onupdate.callCount).equals(1)
done()
}, FRAME_BUDGET)
})
o(onupdate.callCount).equals(1)
})
o("redraws on events", function(done) {
@ -133,23 +138,21 @@ o.spec("route", function() {
}
})
callAsync(function() {
root.firstChild.dispatchEvent(e)
root.firstChild.dispatchEvent(e)
o(oninit.callCount).equals(1)
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)
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)
// Wrapped to give time for the rate-limited redraw to fire
setTimeout(function() {
o(onupdate.callCount).equals(1)
done()
}, FRAME_BUDGET)
})
done()
}, FRAME_BUDGET * 2)
})
o("event handlers can skip redraw", function(done) {
@ -175,21 +178,19 @@ o.spec("route", function() {
}
})
callAsync(function() {
root.firstChild.dispatchEvent(e)
root.firstChild.dispatchEvent(e)
o(oninit.callCount).equals(1)
o(oninit.callCount).equals(1)
// Wrapped to ensure no redraw fired
setTimeout(function() {
o(onupdate.callCount).equals(0)
// Wrapped to ensure no redraw fired
setTimeout(function() {
o(onupdate.callCount).equals(0)
done()
}, FRAME_BUDGET)
})
done()
}, FRAME_BUDGET)
})
o("changes location on route.link", function(done) {
o("changes location on route.link", function() {
var e = $window.document.createEvent("MouseEvents")
e.initEvent("click", true, true)
@ -211,20 +212,16 @@ o.spec("route", function() {
}
})
callAsync(function() {
var slash = prefix[0] === "/" ? "" : "/"
var slash = prefix[0] === "/" ? "" : "/"
o($window.location.href).equals(env.protocol + "//" + (env.hostname === "/" ? "" : env.hostname) + slash + (prefix ? prefix + "/" : ""))
o($window.location.href).equals(env.protocol + "//" + (env.hostname === "/" ? "" : env.hostname) + slash + (prefix ? prefix + "/" : ""))
root.firstChild.dispatchEvent(e)
root.firstChild.dispatchEvent(e)
o($window.location.href).equals(env.protocol + "//" + (env.hostname === "/" ? "" : env.hostname) + slash + (prefix ? prefix + "/" : "") + "test")
done()
})
o($window.location.href).equals(env.protocol + "//" + (env.hostname === "/" ? "" : env.hostname) + slash + (prefix ? prefix + "/" : "") + "test")
})
o("accepts RouteResolver", function(done) {
o("accepts RouteResolver", function() {
var matchCount = 0
var renderCount = 0
var Component = {
@ -232,107 +229,93 @@ o.spec("route", function() {
return m("div")
}
}
$window.location.href = prefix + "/"
$window.location.href = prefix + "/abc"
route(root, "/abc", {
"/:id" : {
onmatch: function(vnode, resolve) {
onmatch: function(resolve, args, requestedPath) {
matchCount++
o(vnode.attrs.id).equals("abc")
o(route.get()).equals("/abc")
o(args.id).equals("abc")
o(requestedPath).equals("/abc")
resolve(Component)
},
render: function(vnode) {
renderCount++
o(vnode.attrs.id).equals("abc")
return vnode
},
},
})
setTimeout(function() {
o(matchCount).equals(1)
o(renderCount).equals(1)
o(root.firstChild.nodeName).equals("DIV")
done()
}, FRAME_BUDGET)
o(matchCount).equals(1)
o(renderCount).equals(1)
o(root.firstChild.nodeName).equals("DIV")
})
o("accepts RouteResolver without `render` method as payload", function(done) {
o("accepts RouteResolver without `render` method as payload", function() {
var matchCount = 0
var Component = {
view: function() {
return m("div")
}
}
$window.location.href = prefix + "/"
$window.location.href = prefix + "/abc"
route(root, "/abc", {
"/:id" : {
onmatch: function(vnode, resolve) {
onmatch: function(resolve, args, requestedPath) {
matchCount++
o(vnode.attrs.id).equals("abc")
o(route.get()).equals("/abc")
o(args.id).equals("abc")
o(requestedPath).equals("/abc")
resolve(Component)
},
},
})
setTimeout(function() {
o(matchCount).equals(1)
o(root.firstChild.nodeName).equals("DIV")
done()
}, FRAME_BUDGET)
o(matchCount).equals(1)
o(root.firstChild.nodeName).equals("DIV")
})
o("accepts RouteResolver without `onmatch` method as payload", function(done) {
o("accepts RouteResolver without `onmatch` method as payload", function() {
var renderCount = 0
var Component = {
view: function() {
return m("div")
}
}
$window.location.href = prefix + "/"
$window.location.href = prefix + "/abc"
route(root, "/abc", {
"/:id" : {
render: function(vnode) {
renderCount++
o(vnode.attrs.id).equals("abc")
return m(Component)
},
},
})
setTimeout(function() {
o(root.firstChild.nodeName).equals("DIV")
done()
}, FRAME_BUDGET)
o(root.firstChild.nodeName).equals("DIV")
})
o("RouteResolver `render` does not have component semantics", function(done, timeout) {
timeout(60)
o("RouteResolver `render` does not have component semantics", function(done) {
var renderCount = 0
var A = {
view: function() {
return m("div")
}
}
$window.location.href = prefix + "/"
$window.location.href = prefix + "/a"
route(root, "/a", {
"/a" : {
render: function(vnode) {
@ -345,22 +328,20 @@ o.spec("route", function() {
},
},
})
var dom = root.firstChild
o(root.firstChild.nodeName).equals("DIV")
route.set("/b")
setTimeout(function() {
var dom = root.firstChild
o(root.firstChild.nodeName).equals("DIV")
route.set("/b")
setTimeout(function() {
o(root.firstChild).equals(dom)
done()
}, FRAME_BUDGET)
o(root.firstChild).equals(dom)
done()
}, FRAME_BUDGET)
})
o("calls onmatch and view correct number of times", function(done) {
o("calls onmatch and view correct number of times", function() {
var matchCount = 0
var renderCount = 0
var Component = {
@ -368,11 +349,11 @@ o.spec("route", function() {
return m("div")
}
}
$window.location.href = prefix + "/"
route(root, "/", {
"/" : {
onmatch: function(vnode, resolve) {
onmatch: function(resolve) {
matchCount++
resolve(Component)
},
@ -383,69 +364,63 @@ o.spec("route", function() {
},
})
callAsync(function() {
o(matchCount).equals(1)
o(renderCount).equals(1)
redraw.publish()
o(matchCount).equals(1)
o(renderCount).equals(1)
setTimeout(function() {
o(matchCount).equals(1)
o(renderCount).equals(2)
done()
}, FRAME_BUDGET)
})
redraw.publish(true)
o(matchCount).equals(1)
o(renderCount).equals(2)
})
o("onmatch can redirect to another route", function(done) {
var redirected = false
var redirected = false
$window.location.href = prefix + "/"
route(root, "/a", {
"/a" : {
onmatch: function() {
route.set("/b")
}
},
"/b" : {
view: function(vnode){
redirected = true
}
}
})
$window.location.href = prefix + "/a"
route(root, "/a", {
"/a" : {
onmatch: function() {
route.set("/b")
}
},
"/b" : {
view: function(vnode){
redirected = true
}
}
})
setTimeout(function() {
o(redirected).equals(true)
setTimeout(function() {
o(redirected).equals(true)
done()
}, FRAME_BUDGET)
})
done()
}, FRAME_BUDGET)
})
o("onmatch can redirect to another route that has RouteResolver", function(done) {
var redirected = false
var redirected = false
$window.location.href = prefix + "/"
route(root, "/a", {
"/a" : {
onmatch: function() {
route.set("/b")
}
},
"/b" : {
render: function(vnode){
redirected = true
}
}
})
$window.location.href = prefix + "/a"
route(root, "/a", {
"/a" : {
onmatch: function() {
route.set("/b")
}
},
"/b" : {
render: function(vnode){
redirected = true
}
}
})
setTimeout(function() {
o(redirected).equals(true)
setTimeout(function() {
o(redirected).equals(true)
done()
}, FRAME_BUDGET)
})
done()
}, FRAME_BUDGET)
})
o("onmatch resolution callback resolves at most once", function(done) {
var resolveCount = 0
var resolvedComponent
@ -456,7 +431,7 @@ o.spec("route", function() {
$window.location.href = prefix + "/"
route(root, "/", {
"/": {
onmatch: function(vnode, resolve) {
onmatch: function(resolve) {
resolve(A)
resolve(B)
callAsync(function() {resolve(C)})
@ -474,15 +449,114 @@ o.spec("route", function() {
done()
}, FRAME_BUDGET)
})
o("calling route.set invalidates pending onmatch resolution", function(done, timeout) {
timeout(100)
var resolved
o("the previous view redraws while onmatch resolution is pending (#1268)", function(done) {
var view = o.spy()
var onmatch = o.spy()
$window.location.href = prefix + "/a"
route(root, "/", {
"/a": {view: view},
"/b": {onmatch: onmatch}
})
o(view.callCount).equals(1)
o(onmatch.callCount).equals(0)
route.set("/b")
setTimeout(function(){
o(view.callCount).equals(1)
o(onmatch.callCount).equals(1)
redraw.publish(true)
o(view.callCount).equals(2)
o(onmatch.callCount).equals(1)
done()
}, FRAME_BUDGET)
})
o("m.route.set(m.route.get()) re-runs the resolution logic (#1180)", function(done){
var onmatch = o.spy(function(resolve){resolve()})
$window.location.href = prefix + "/"
route(root, '/', {
"/":{
onmatch: onmatch,
render: function(){return m("div")}
}
})
o(onmatch.callCount).equals(1)
route.set(route.get())
setTimeout(function() {
o(onmatch.callCount).equals(2)
done()
}, FRAME_BUDGET)
})
o("m.route.get() returns the last fully resolved route (#1276)", function(done){
$window.location.href = prefix + "/"
route(root, "/", {
"/": {view: function(){}},
"/2": {onmatch: function(){}}
})
o(route.get()).equals("/")
route.set("/2")
setTimeout(function(){
o(route.get()).equals("/")
done()
}, FRAME_BUDGET)
})
o("routing with RouteResolver works more than once (#1286)", function(done, timeout){
timeout(FRAME_BUDGET * 3)
$window.location.href = prefix + "/a"
route(root, '/a', {
'/a': {
render: function() {
return m("a", "a")
}
},
'/b': {
render: function() {
return m("b", "b")
}
}
})
route.set('/b')
setTimeout(function(){
route.set('/a')
setTimeout(function(){
o(root.firstChild.nodeName).equals("A")
done()
}, FRAME_BUDGET)
}, FRAME_BUDGET)
})
o("calling route.set invalidates pending onmatch resolution", function(done, timeout) {
timeout(50)
var resolved
$window.location.href = prefix + "/a"
route(root, "/a", {
"/a": {
onmatch: function(vnode, resolve) {
onmatch: function(resolve) {
setTimeout(resolve, 20)
},
render: function(vnode) {resolved = "a"}
@ -491,15 +565,14 @@ o.spec("route", function() {
view: function() {resolved = "b"}
}
})
setTimeout(function() {
route.set("/b")
setTimeout(function() {
o(resolved).equals("b")
done()
}, 30)
}, FRAME_BUDGET)
route.set("/b")
setTimeout(function() {
o(resolved).equals("b")
done()
}, 30)
})
})
})

View file

@ -52,13 +52,13 @@ Argument | Type | Required | Description
##### route.get
Returns the current routing path, without the prefix.
Returns the last fully resolved routing path, without the prefix. It may differ from the path displayed in the location bar while an asynchronous route is [pending resolution](#code-splitting).
`path = m.route.get()`
Argument | Type | Required | Description
----------------- | --------- | -------- | ---
**returns** | String | | Returns the current path
**returns** | String | | Returns the last fully resolved path
##### route.prefix
@ -94,14 +94,12 @@ This method also allows you to asynchronously define what component will be rend
`routeResolver.onmatch(vnode, resolve)`
Argument | Type | Description
------------------- | --------------------- | ---
`vnode` | `Vnode` | A [vnode](vnodes.md) whose attributes object contains routing parameters. If the routeResolver does not have a `resolve` method, the vnode's `tag` field defaults to a `div`
`vnode.attrs` | `Object` | The [routing parameters](#routing-parameters)
`vnode.attrs.path` | `String` | The current router path, including interpolated routing parameter values, but without the prefix. Same value as `m.route.get()`
`vnode.attrs.route` | `String` | The matched route
`resolve` | `Function(Component)` | Call this function with a component as the first argument to use it as the route's component
**returns** | | Returns `undefined`
Argument | Type | Description
--------------- | --------------------- | ---
`resolve` | `Function(Component)` | Call this function with a component as the first argument to use it as the route's component
`args` | `Object` | The [routing parameters](#routing-parameters)
`requestedPath` | `String` | The router path requested by the last routing action, including interpolated routing parameter values, but without the prefix. When `onmatch` is called, the resolution for this path is not complete and `m.route.get()` still returns the previous path.
**returns** | | Returns `undefined`
##### routeResolver.render
@ -113,8 +111,6 @@ Argument | Type | Description
------------------- | --------------- | -----------
`vnode` | `Object` | A [vnode](vnodes.md) whose attributes object contains routing parameters. If the routeResolver does not have a `resolve` method, the vnode's `tag` field defaults to a `div`
`vnode.attrs` | `Object` | A [vnode](vnodes.md) whose attributes object contains routing parameters. If the routeResolver does not have a `resolve` method, the vnode defaults to a `div`
`vnode.attrs.path` | `String` | The current router path, including interpolated routing parameter values, but without the prefix. Same value as `m.route.get()`
`vnode.attrs.route` | `String` | The matched route
**returns** | `Vnode` | Returns a vnode
---
@ -266,12 +262,12 @@ m.route.prefix("/my-app")
### Advanced component resolution
Instead of mapping a component to a route, you can specify a RouteResolver object. A RouteResolver object contains a `onmatch()` method and a optionally a `render()` method.
Instead of mapping a component to a route, you can specify a RouteResolver object. A RouteResolver object contains a `onmatch()` method and/or a `render()` method.
```javascript
m.route(document.body, "/", {
"/": {
onmatch: function(vnode, resolve) {
onmatch: function(resolve, args, requestedPath) {
resolve(Home)
},
render: function(vnode) {
@ -329,7 +325,7 @@ var Login = {
m.route(document.body, "/secret", {
"/secret": {
onmatch: function(vnode, resolve) {
onmatch: function(resolve) {
if (isLoggedIn) resolve(Home)
else m.route.set("/login")
},
@ -377,7 +373,7 @@ function load(file, done) {
m.route(document.body, "/", {
"/": {
onmatch: function(vnode, resolve) {
onmatch: function(resolve) {
load("Home.js", resolve)
},
},
@ -391,7 +387,7 @@ Fortunately, there are a number of tools that facilitate the task of bundling mo
```javascript
m.route(document.body, "/", {
"/": {
onmatch: function(vnode, resolve) {
onmatch: function(resolve) {
// using Webpack async code splitting
require(['./Home.js'], resolve)
},

View file

@ -10,8 +10,8 @@ var Stream = require("./stream")
requestService.setCompletionCallback(redrawService.publish)
m.route = require("./route")
m.mount = require("./mount")
m.route = require("./route")
m.withAttr = require("./util/withAttr")
m.prop = Stream
m.render = renderService.render

View file

@ -1,4 +1,3 @@
var renderService = require("./render")
var redrawService = require("./redraw")
var mount = require("./mount")
module.exports = require("./api/router")(window, renderService, redrawService)
module.exports = require("./api/router")(window, mount)

View file

@ -16,6 +16,18 @@ module.exports = function($window) {
return data
}
var asyncId
function debounceAsync(f) {
return function() {
if (asyncId != null) return
asyncId = callAsync(function() {
asyncId = null
f()
})
}
}
function parsePath(path, queryData, hashData) {
var queryIndex = path.indexOf("?")
var hashIndex = path.indexOf("#")
@ -67,7 +79,7 @@ module.exports = function($window) {
}
function defineRoutes(routes, resolve, reject) {
if (supportsPushState) $window.onpopstate = resolveRoute
if (supportsPushState) $window.onpopstate = debounceAsync(resolveRoute)
else if (prefix.charAt(0) === "#") $window.onhashchange = resolveRoute
resolveRoute()
@ -76,25 +88,23 @@ module.exports = function($window) {
var params = {}
var pathname = parsePath(path, params, params)
callAsync(function() {
for (var route in routes) {
var matcher = new RegExp("^" + route.replace(/:[^\/]+?\.{3}/g, "(.*?)").replace(/:[^\/]+/g, "([^\\/]+)") + "\/?$")
for (var route in routes) {
var matcher = new RegExp("^" + route.replace(/:[^\/]+?\.{3}/g, "(.*?)").replace(/:[^\/]+/g, "([^\\/]+)") + "\/?$")
if (matcher.test(pathname)) {
pathname.replace(matcher, function() {
var keys = route.match(/:[^\/]+/g) || []
var values = [].slice.call(arguments, 1, -2)
for (var i = 0; i < keys.length; i++) {
params[keys[i].replace(/:|\./g, "")] = decodeURIComponent(values[i])
}
resolve(routes[route], params, path, route)
})
return
}
if (matcher.test(pathname)) {
pathname.replace(matcher, function() {
var keys = route.match(/:[^\/]+/g) || []
var values = [].slice.call(arguments, 1, -2)
for (var i = 0; i < keys.length; i++) {
params[keys[i].replace(/:|\./g, "")] = decodeURIComponent(values[i])
}
resolve(routes[route], params, path, route)
})
return
}
}
reject(path, params)
})
reject(path, params)
}
return resolveRoute
}

View file

@ -285,18 +285,14 @@ o.spec("Router.defineRoutes", function() {
})
})
o("replays", function(done) {
o("replays", function() {
$window.location.href = prefix + "/test"
var replay = router.defineRoutes({"/test": {data: 1}}, onRouteChange, onFail)
replay()
callAsync(function() {
o(onRouteChange.callCount).equals(2)
o(onRouteChange.args).deepEquals([{data: 1}, {}, "/test", "/test"])
o(onFail.callCount).equals(0)
done()
})
o(onRouteChange.callCount).equals(2)
o(onRouteChange.args).deepEquals([{data: 1}, {}, "/test", "/test"])
o(onFail.callCount).equals(0)
})
})
})

View file

@ -164,9 +164,11 @@ o.spec("api", function() {
setTimeout(function() {
m.route.set("/b")
o(m.route.get()).equals("/b")
setTimeout(function() {
o(m.route.get()).equals("/b")
done()
done()
}, FRAME_BUDGET)
}, FRAME_BUDGET)
})
})