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.
266 lines
8.5 KiB
JavaScript
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
|
|
}
|