* De-servicify router (mostly) Still uses the redraw service, but it no longer has an intermediate service of its own. Also, did a *lot* of test deduplication in this. About 30-40% of the router service tests were already tested on the main router API instance itself. Bundle size decreased from 9560 to 9548 bytes min+gzip. * Merge `m.mount` + `m.redraw`, update router Simplifies the router and redraw mechanism, and makes it much easier to keep predictable. Bundle size down to 9433 bytes min+gzip, docs updated accordingly. * Make `mithril/render` just return the `m.render` function directly. * Deservicify `m.render`, revise `m.route` - You now have to use `mithril/render/render` directly if you want an implicit redraw function. (This will likely be going away in v3.) - Revise `m.route` to only `key` components * Add `redraw` to `m.render`, deservicify requests * Test error logging * Update docs + changelog [skip ci]
185 lines
5.9 KiB
JavaScript
185 lines
5.9 KiB
JavaScript
"use strict"
|
|
|
|
var Vnode = require("../render/vnode")
|
|
var Promise = require("../promise/promise")
|
|
|
|
var buildPathname = require("../pathname/build")
|
|
var parsePathname = require("../pathname/parse")
|
|
var compileTemplate = require("../pathname/compileTemplate")
|
|
var assign = require("../pathname/assign")
|
|
|
|
var sentinel = {}
|
|
|
|
module.exports = function($window, mountRedraw) {
|
|
var callAsync = typeof setImmediate === "function" ? setImmediate : setTimeout
|
|
var supportsPushState = typeof $window.history.pushState === "function"
|
|
var routePrefix = "#!"
|
|
var fireAsync
|
|
|
|
function setPath(path, data, options) {
|
|
path = buildPathname(path, data)
|
|
if (fireAsync != null) {
|
|
fireAsync()
|
|
var state = options ? options.state : null
|
|
var title = options ? options.title : null
|
|
if (options && options.replace) $window.history.replaceState(state, title, routePrefix + path)
|
|
else $window.history.pushState(state, title, routePrefix + path)
|
|
}
|
|
else {
|
|
$window.location.href = routePrefix + path
|
|
}
|
|
}
|
|
|
|
var currentResolver = sentinel, 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")
|
|
// 0 = start
|
|
// 1 = init
|
|
// 2 = ready
|
|
var state = 0
|
|
|
|
var compiled = Object.keys(routes).map(function(route) {
|
|
if (route[0] !== "/") throw new SyntaxError("Routes must start with a `/`")
|
|
if ((/:([^\/\.-]+)(\.{3})?:/).test(route)) {
|
|
throw new SyntaxError("Route parameter names must be separated with either `/`, `.`, or `-`")
|
|
}
|
|
return {
|
|
route: route,
|
|
component: routes[route],
|
|
check: compileTemplate(route),
|
|
}
|
|
})
|
|
var onremove, asyncId
|
|
|
|
fireAsync = null
|
|
|
|
if (defaultRoute != null) {
|
|
var defaultData = parsePathname(defaultRoute)
|
|
|
|
if (!compiled.some(function (i) { return i.check(defaultData) })) {
|
|
throw new ReferenceError("Default route doesn't match any known routes")
|
|
}
|
|
}
|
|
|
|
function resolveRoute() {
|
|
// Consider the pathname holistically. The prefix might even be invalid,
|
|
// but that's not our problem.
|
|
var prefix = $window.location.hash
|
|
if (routePrefix[0] !== "#") {
|
|
prefix = $window.location.search + prefix
|
|
if (routePrefix[0] !== "?") {
|
|
prefix = $window.location.pathname + prefix
|
|
if (prefix[0] !== "/") prefix = "/" + prefix
|
|
}
|
|
}
|
|
// This seemingly useless `.concat()` speeds up the tests quite a bit,
|
|
// since the representation is consistently a relatively poorly
|
|
// optimized cons string.
|
|
var path = prefix.concat()
|
|
.replace(/(?:%[a-f89][a-f0-9])+/gim, decodeURIComponent)
|
|
.slice(routePrefix.length)
|
|
var data = parsePathname(path)
|
|
|
|
assign(data.params, $window.history.state)
|
|
|
|
for (var i = 0; i < compiled.length; i++) {
|
|
if (compiled[i].check(data)) {
|
|
var payload = compiled[i].component
|
|
var route = compiled[i].route
|
|
var update = lastUpdate = function(routeResolver, comp) {
|
|
if (update !== lastUpdate) return
|
|
component = comp != null && (typeof comp.view === "function" || typeof comp === "function")? comp : "div"
|
|
attrs = data.params, currentPath = path, lastUpdate = null
|
|
currentResolver = routeResolver.render ? routeResolver : null
|
|
if (state === 2) mountRedraw.redraw()
|
|
else {
|
|
state = 2
|
|
mountRedraw.redraw.sync()
|
|
}
|
|
}
|
|
if (payload.view || typeof payload === "function") update({}, payload)
|
|
else {
|
|
if (payload.onmatch) {
|
|
Promise.resolve(payload.onmatch(data.params, path, route)).then(function(resolved) {
|
|
update(payload, resolved)
|
|
}, function () {
|
|
if (path === defaultRoute) throw new Error("Could not resolve default route " + defaultRoute)
|
|
setPath(defaultRoute, null, {replace: true})
|
|
})
|
|
}
|
|
else update(payload, "div")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if (path === defaultRoute) throw new Error("Could not resolve default route " + defaultRoute)
|
|
setPath(defaultRoute, null, {replace: true})
|
|
}
|
|
|
|
if (supportsPushState) {
|
|
onremove = function() {
|
|
$window.removeEventListener("popstate", fireAsync, false)
|
|
}
|
|
$window.addEventListener("popstate", fireAsync = function() {
|
|
if (asyncId) return
|
|
asyncId = callAsync(function() {
|
|
asyncId = null
|
|
resolveRoute()
|
|
})
|
|
}, false)
|
|
} else if (routePrefix[0] === "#") {
|
|
onremove = function() {
|
|
$window.removeEventListener("hashchange", resolveRoute, false)
|
|
}
|
|
$window.addEventListener("hashchange", resolveRoute, false)
|
|
}
|
|
|
|
return mountRedraw.mount(root, {
|
|
onbeforeupdate: function() {
|
|
state = state ? 2 : 1
|
|
return !(!state || sentinel === currentResolver)
|
|
},
|
|
oncreate: resolveRoute,
|
|
onremove: onremove,
|
|
view: function() {
|
|
if (!state || sentinel === currentResolver) return
|
|
// Wrap in a fragment to preserve existing key semantics
|
|
var vnode = [Vnode(component, attrs.key, attrs)]
|
|
if (currentResolver) vnode = currentResolver.render(vnode[0])
|
|
return vnode
|
|
},
|
|
})
|
|
}
|
|
route.set = function(path, data, options) {
|
|
if (lastUpdate != null) {
|
|
options = options || {}
|
|
options.replace = true
|
|
}
|
|
lastUpdate = null
|
|
setPath(path, data, options)
|
|
}
|
|
route.get = function() {return currentPath}
|
|
route.prefix = function(prefix) {routePrefix = prefix}
|
|
var link = function(options, vnode) {
|
|
vnode.dom.setAttribute("href", routePrefix + vnode.attrs.href)
|
|
vnode.dom.onclick = function(e) {
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.which === 2) return
|
|
e.preventDefault()
|
|
e.redraw = false
|
|
var href = this.getAttribute("href")
|
|
if (href.indexOf(routePrefix) === 0) href = href.slice(routePrefix.length)
|
|
route.set(href, undefined, options)
|
|
}
|
|
}
|
|
route.link = function(args) {
|
|
if (args.tag == null) return link.bind(link, args)
|
|
return link({}, args)
|
|
}
|
|
route.param = function(key) {
|
|
if(typeof attrs !== "undefined" && typeof key !== "undefined") return attrs[key]
|
|
return attrs
|
|
}
|
|
|
|
return route
|
|
}
|