mithril-vndb/api/router.js
Isiah Meadows b98ab29efd
Make errors and their messages more accurate and helpful (#2536)
Also, I normalized them to all be sentences for consistency, and I moved
the reentrancy check from `m.mount` to `m.render` to be a little more
helpful. The router change during mounting is inconsequential and only
to avoid the new modified error, and the change to the update loop is to
send the original error if an error occurred while initializing the
default route. (This is all around more useful anyways.)

And while I was at it, I fixed an obscure bug with sync redraws.
2019-09-30 16:08:04 -04:00

266 lines
8.5 KiB
JavaScript

"use strict"
var Vnode = require("../render/vnode")
var m = require("../render/hyperscript")
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 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, route.prefix + path)
else $window.history.pushState(state, title, route.prefix + path)
}
else {
$window.location.href = route.prefix + path
}
}
var currentResolver = sentinel, component, attrs, currentPath, lastUpdate
var SKIP = route.SKIP = {}
function route(root, defaultRoute, routes) {
if (!root) throw new TypeError("DOM element being rendered to does not exist.")
// 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 callAsync = typeof setImmediate === "function" ? setImmediate : setTimeout
var p = Promise.resolve()
var scheduled = false
var onremove
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() {
scheduled = false
// Consider the pathname holistically. The prefix might even be invalid,
// but that's not our problem.
var prefix = $window.location.hash
if (route.prefix[0] !== "#") {
prefix = $window.location.search + prefix
if (route.prefix[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(route.prefix.length)
var data = parsePathname(path)
assign(data.params, $window.history.state)
function reject(e) {
console.error(e)
setPath(defaultRoute, null, {replace: true})
}
loop(0)
function loop(i) {
// 0 = init
// 1 = scheduled
// 2 = done
for (; i < compiled.length; i++) {
if (compiled[i].check(data)) {
var payload = compiled[i].component
var matchedRoute = compiled[i].route
var localComp = payload
var update = lastUpdate = function(comp) {
if (update !== lastUpdate) return
if (comp === SKIP) return loop(i + 1)
component = comp != null && (typeof comp.view === "function" || typeof comp === "function")? comp : "div"
attrs = data.params, currentPath = path, lastUpdate = null
currentResolver = payload.render ? payload : null
if (state === 2) mountRedraw.redraw()
else {
state = 2
mountRedraw.redraw.sync()
}
}
// There's no understating how much I *wish* I could
// use `async`/`await` here...
if (payload.view || typeof payload === "function") {
payload = {}
update(localComp)
}
else if (payload.onmatch) {
p.then(function () {
return payload.onmatch(data.params, path, matchedRoute)
}).then(update, path === defaultRoute ? null : reject)
}
else update("div")
return
}
}
if (path === defaultRoute) {
throw new Error("Could not resolve default route " + defaultRoute + ".")
}
setPath(defaultRoute, null, {replace: true})
}
}
// Set it unconditionally so `m.route.set` and `m.route.Link` both work,
// even if neither `pushState` nor `hashchange` are supported. It's
// cleared if `hashchange` is used, since that makes it automatically
// async.
fireAsync = function() {
if (!scheduled) {
scheduled = true
callAsync(resolveRoute)
}
}
if (typeof $window.history.pushState === "function") {
onremove = function() {
$window.removeEventListener("popstate", fireAsync, false)
}
$window.addEventListener("popstate", fireAsync, false)
} else if (route.prefix[0] === "#") {
fireAsync = null
onremove = function() {
$window.removeEventListener("hashchange", resolveRoute, false)
}
$window.addEventListener("hashchange", resolveRoute, false)
}
mountRedraw.mount(root, {
onbeforeupdate: function() {
state = state ? 2 : 1
return !(!state || sentinel === currentResolver)
},
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
},
})
resolveRoute()
}
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 = "#!"
route.Link = {
view: function(vnode) {
var options = vnode.attrs.options
// Remove these so they don't get overwritten
var attrs = {}, onclick, href
assign(attrs, vnode.attrs)
// The first two are internal, but the rest are magic attributes
// that need censored to not screw up rendering.
attrs.selector = attrs.options = attrs.key = attrs.oninit =
attrs.oncreate = attrs.onbeforeupdate = attrs.onupdate =
attrs.onbeforeremove = attrs.onremove = null
// Do this now so we can get the most current `href` and `disabled`.
// Those attributes may also be specified in the selector, and we
// should honor that.
var child = m(vnode.attrs.selector || "a", attrs, vnode.children)
// Let's provide a *right* way to disable a route link, rather than
// letting people screw up accessibility on accident.
//
// The attribute is coerced so users don't get surprised over
// `disabled: 0` resulting in a button that's somehow routable
// despite being visibly disabled.
if (child.attrs.disabled = Boolean(child.attrs.disabled)) {
child.attrs.href = null
child.attrs["aria-disabled"] = "true"
// If you *really* do want to do this on a disabled link, use
// an `oncreate` hook to add it.
child.attrs.onclick = null
} else {
onclick = child.attrs.onclick
href = child.attrs.href
child.attrs.href = route.prefix + href
child.attrs.onclick = function(e) {
var result
if (typeof onclick === "function") {
result = onclick.call(e.currentTarget, e)
} else if (onclick == null || typeof onclick !== "object") {
// do nothing
} else if (typeof onclick.handleEvent === "function") {
onclick.handleEvent(e)
}
// Adapted from React Router's implementation:
// https://github.com/ReactTraining/react-router/blob/520a0acd48ae1b066eb0b07d6d4d1790a1d02482/packages/react-router-dom/modules/Link.js
//
// Try to be flexible and intuitive in how we handle links.
// Fun fact: links aren't as obvious to get right as you
// would expect. There's a lot more valid ways to click a
// link than this, and one might want to not simply click a
// link, but right click or command-click it to copy the
// link target, etc. Nope, this isn't just for blind people.
if (
// Skip if `onclick` prevented default
result !== false && !e.defaultPrevented &&
// Ignore everything but left clicks
(e.button === 0 || e.which === 0 || e.which === 1) &&
// Let the browser handle `target=_blank`, etc.
(!e.currentTarget.target || e.currentTarget.target === "_self") &&
// No modifier keys
!e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey
) {
e.preventDefault()
e.redraw = false
route.set(href, null, options)
}
}
}
return child
},
}
route.param = function(key) {
return attrs && key != null ? attrs[key] : attrs
}
return route
}