diff --git a/README.md b/README.md index 5088ce20..78c450fe 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ mithril.js [![NPM Version](https://img.shields.io/npm/v/mithril.svg)](https://ww ## What is Mithril? -A modern client-side Javascript framework for building Single Page Applications. It's small (9.31 KB gzipped), fast and provides routing and XHR utilities out of the box. +A modern client-side Javascript framework for building Single Page Applications. It's small (9.53 KB gzipped), fast and provides routing and XHR utilities out of the box. Mithril is used by companies like Vimeo and Nike, and open source platforms like Lichess 👍. diff --git a/api/router.js b/api/router.js index 3357e7f8..365399cf 100644 --- a/api/router.js +++ b/api/router.js @@ -1,6 +1,7 @@ "use strict" var Vnode = require("../render/vnode") +var m = require("../render/hyperscript") var Promise = require("../promise/promise") var buildPathname = require("../pathname/build") @@ -11,9 +12,6 @@ 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) { @@ -22,16 +20,19 @@ module.exports = function($window, mountRedraw) { 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) + 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 = routePrefix + path + $window.location.href = route.prefix + path } } var currentResolver = sentinel, component, attrs, currentPath, lastUpdate - var route = function(root, defaultRoute, routes) { + + var SKIP = route.SKIP = {} + + function route(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 @@ -49,7 +50,10 @@ module.exports = function($window, mountRedraw) { check: compileTemplate(route), } }) - var onremove, asyncId + var callAsync = typeof setImmediate === "function" ? setImmediate : setTimeout + var p = Promise.resolve() + var scheduled = false + var onremove fireAsync = null @@ -62,12 +66,13 @@ module.exports = function($window, mountRedraw) { } 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 (routePrefix[0] !== "#") { + if (route.prefix[0] !== "#") { prefix = $window.location.search + prefix - if (routePrefix[0] !== "?") { + if (route.prefix[0] !== "?") { prefix = $window.location.pathname + prefix if (prefix[0] !== "/") prefix = "/" + prefix } @@ -77,58 +82,75 @@ module.exports = function($window, mountRedraw) { // optimized cons string. var path = prefix.concat() .replace(/(?:%[a-f89][a-f0-9])+/gim, decodeURIComponent) - .slice(routePrefix.length) + .slice(route.prefix.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 - } + function fail() { + if (path === defaultRoute) throw new Error("Could not resolve default route " + defaultRoute) + setPath(defaultRoute, null, {replace: true}) } - if (path === defaultRoute) throw new Error("Could not resolve default route " + defaultRoute) - 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, fail) + } + else update("div") + return + } + } + fail() + } } - if (supportsPushState) { + // 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 = function() { - if (asyncId) return - asyncId = callAsync(function() { - asyncId = null - resolveRoute() - }) - }, false) - } else if (routePrefix[0] === "#") { + $window.addEventListener("popstate", fireAsync, false) + } else if (route.prefix[0] === "#") { + fireAsync = null onremove = function() { $window.removeEventListener("hashchange", resolveRoute, false) } @@ -160,25 +182,77 @@ module.exports = function($window, mountRedraw) { 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.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) + attrs.component = null + attrs.options = null + attrs.key = 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.component || "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 + ) return + e.preventDefault() + e.redraw = false + route.set(href, null, options) + } + } + return child + }, } route.param = function(key) { - if(typeof attrs !== "undefined" && typeof key !== "undefined") return attrs[key] - return attrs + return attrs && key != null ? attrs[key] : attrs } return route diff --git a/api/tests/test-router.js b/api/tests/test-router.js index b0d36bab..8bbeed77 100644 --- a/api/tests/test-router.js +++ b/api/tests/test-router.js @@ -2,24 +2,75 @@ // Low-priority TODO: remove the dependency on the renderer here. 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 coreRenderer = require("../../render/render") -var callAsync = require("../../test-utils/callAsync") var apiMountRedraw = require("../../api/mount-redraw") var apiRouter = require("../../api/router") var Promise = require("../../promise/promise") o.spec("route", function() { + // Note: the `n` parameter used in calls to this are generally found by + // either trial-and-error or by studying the source. If tests are failing, + // find the failing assertions, set `n` to about 10 on the preceding call to + // `waitCycles`, then drop them down incrementally until it fails. The last + // one to succeed is the one you want to keep. And just do that for each + // failing assertion, and it'll eventually work. + // + // This is effectively what I did when designing this and hooking everything + // up. (It would be so much easier to just be able to run the calls with a + // different event loop and just turn it until I get what I want, but JS + // lacks that functionality.) + + // Use precisely what `m.route` uses, for consistency and to ensure timings + // are aligned. + var waitFunc = typeof setImmediate === "function" ? setImmediate : setTimeout + function waitCycles(n) { + n = Math.max(n, 1) + return new Promise(function(resolve) { + return loop() + function loop() { + if (n === 0) resolve() + else { n--; waitFunc(loop) } + } + }) + } + 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 $window, root, mountRedraw, route, throttleMock + var nextID = 0 + var currentTest = 0 + + // Once done, a root should no longer be alive. This verifies + // that, and it's a *very* subtle test bug that can lead to + // some rather unusual consequences. If this fails, use + // `waitCycles(n)` to avoid this. + function lock(func) { + var id = currentTest + var start = Date.now() + try { + throw new Error() + } catch (trace) { + return function() { + // This *will* cause a test failure. + if (id != null && id !== currentTest) { + id = undefined + trace.message = "called " + + (Date.now() - start) + "ms after test end" + console.error(trace.stack) + o("in test").equals("not in test") + } + return func.apply(this, arguments) + } + } + } o.beforeEach(function() { + currentTest = nextID++ $window = browserMock(env) throttleMock = throttleMocker() @@ -27,17 +78,18 @@ o.spec("route", function() { mountRedraw = apiMountRedraw(coreRenderer($window), throttleMock.schedule, console) route = apiRouter($window, mountRedraw) - route.prefix(prefix) + route.prefix = prefix }) o.afterEach(function() { o(throttleMock.queueLength()).equals(0) + currentTest = -1 // doesn't match any test }) o("throws on invalid `root` DOM node", function() { var threw = false try { - route(null, "/", {"/":{view: function() {}}}) + route(null, "/", {"/":{view: lock(function() {})}}) } catch (e) { threw = true } @@ -48,9 +100,9 @@ o.spec("route", function() { $window.location.href = prefix + "/" route(root, "/", { "/" : { - view: function() { + view: lock(function() { return m("div") - } + }) } }) @@ -61,9 +113,9 @@ o.spec("route", function() { $window.location.href = prefix + "/%C3%B6?%C3%B6=%C3%B6" route(root, "/ö", { "/ö" : { - view: function() { + view: lock(function() { return m("div") - } + }) } }) @@ -74,10 +126,10 @@ o.spec("route", function() { $window.location.href = prefix + "/ö?ö=ö" route(root, "/ö", { "/ö" : { - view: function() { + view: lock(function() { return JSON.stringify(route.param()) + " " + route.get() - } + }) } }) @@ -88,11 +140,11 @@ o.spec("route", function() { $window.location.href = prefix + "/test/x" route(root, "/test/:a", { "/test/:a" : { - view: function(vnode) { + view: lock(function(vnode) { return JSON.stringify(route.param()) + " " + JSON.stringify(vnode.attrs) + " " + route.get() - } + }) } }) @@ -105,11 +157,11 @@ o.spec("route", function() { $window.location.href = prefix + "/test/x/y" route(root, "/test/:a/:b", { "/test/:a/:b" : { - view: function(vnode) { + view: lock(function(vnode) { return JSON.stringify(route.param()) + " " + JSON.stringify(vnode.attrs) + " " + route.get() - } + }) } }) @@ -122,11 +174,11 @@ o.spec("route", function() { $window.location.href = prefix + "/test/x/y" route(root, "/test/:a...", { "/test/:a..." : { - view: function(vnode) { + view: lock(function(vnode) { return JSON.stringify(route.param()) + " " + JSON.stringify(vnode.attrs) + " " + route.get() - } + }) } }) @@ -139,11 +191,11 @@ o.spec("route", function() { $window.location.href = prefix + "/test?a=b&c=d" route(root, "/test", { "/test" : { - view: function(vnode) { + view: lock(function(vnode) { return JSON.stringify(route.param()) + " " + JSON.stringify(vnode.attrs) + " " + route.get() - } + }) } }) @@ -152,21 +204,20 @@ o.spec("route", function() { ) }) - o("redirects to default route if no match", function(done) { + o("redirects to default route if no match", function() { $window.location.href = prefix + "/test" route(root, "/other", { "/other": { - view: function(vnode) { + view: lock(function(vnode) { return JSON.stringify(route.param()) + " " + JSON.stringify(vnode.attrs) + " " + route.get() - } + }) } }) - callAsync(function() { + return waitCycles(1).then(function() { o(root.firstChild.nodeValue).equals("{} {} /other") - done() }) }) @@ -175,10 +226,10 @@ o.spec("route", function() { route(root, "/z/y/x", { "/z/y/x": { - view: function() { return "1" }, + view: lock(function() { return "1" }), }, "/:a...": { - view: function() { return "2" }, + view: lock(function() { return "2" }), }, }) @@ -190,10 +241,10 @@ o.spec("route", function() { route(root, "/z/y/x", { "/:a...": { - view: function() { return "2" }, + view: lock(function() { return "2" }), }, "/z/y/x": { - view: function() { return "1" }, + view: lock(function() { return "1" }), }, }) @@ -205,11 +256,11 @@ o.spec("route", function() { route(root, "/test", { "/test" : { - view: function(vnode) { + view: lock(function(vnode) { return JSON.stringify(route.param()) + " " + JSON.stringify(vnode.attrs) + " " + route.get() - } + }) } }) @@ -236,8 +287,8 @@ o.spec("route", function() { o("routed mount points only redraw asynchronously (constructible component)", function() { var view = o.spy() - var Cmp = function(){} - Cmp.prototype.view = view + var Cmp = lock(function(){}) + Cmp.prototype.view = lock(view) $window.location.href = prefix + "/" route(root, "/", {"/":Cmp}) @@ -256,10 +307,10 @@ o.spec("route", function() { o("routed mount points only redraw asynchronously (closure component)", function() { var view = o.spy() - function Cmp() {return {view: view}} + function Cmp() {return {view: lock(view)}} $window.location.href = prefix + "/" - route(root, "/", {"/":Cmp}) + route(root, "/", {"/":lock(Cmp)}) o(view.callCount).equals(1) @@ -277,9 +328,9 @@ o.spec("route", function() { route(root, "/", { "/" : { - view: function() { + view: lock(function() { return m("div") - } + }) } }) @@ -290,19 +341,19 @@ o.spec("route", function() { o(root.childNodes.length).equals(0) }) - o("default route doesn't break back button", function(done) { + o("default route doesn't break back button", function() { $window.location.href = "http://old.com" $window.location.href = "http://new.com" route(root, "/a", { "/a" : { - view: function() { + view: lock(function() { return m("div") - } + }) } }) - callAsync(function() { + return waitCycles(1).then(function() { o(root.firstChild.nodeName).equals("DIV") o(route.get()).equals("/a") @@ -311,27 +362,23 @@ o.spec("route", function() { o($window.location.pathname).equals("/") o($window.location.hostname).equals("old.com") - - done() }) }) - o("default route does not inherit params", function(done) { + o("default route does not inherit params", function() { $window.location.href = "/invalid?foo=bar" route(root, "/a", { "/a" : { - oninit: init, - view: function() { + oninit: lock(function(vnode) { + o(vnode.attrs.foo).equals(undefined) + }), + view: lock(function() { return m("div") - } + }) } }) - function init(vnode) { - o(vnode.attrs.foo).equals(undefined) - - done() - } + return waitCycles(1) }) o("redraws when render function is executed", function() { @@ -341,12 +388,12 @@ o.spec("route", function() { $window.location.href = prefix + "/" route(root, "/", { "/" : { - view: function() { + view: lock(function() { return m("div", { oninit: oninit, onupdate: onupdate }) - } + }) } }) @@ -369,13 +416,13 @@ o.spec("route", function() { $window.location.href = prefix + "/" route(root, "/", { "/" : { - view: function() { + view: lock(function() { return m("div", { oninit: oninit, onupdate: onupdate, onclick: onclick, }) - } + }) } }) @@ -393,7 +440,7 @@ o.spec("route", function() { o(onupdate.callCount).equals(1) }) - o("event handlers can skip redraw", function(done) { + o("event handlers can skip redraw", function() { var onupdate = o.spy() var oninit = o.spy() var e = $window.document.createEvent("MouseEvents") @@ -403,15 +450,15 @@ o.spec("route", function() { $window.location.href = prefix + "/" route(root, "/", { "/" : { - view: function() { + view: lock(function() { return m("div", { oninit: oninit, onupdate: onupdate, - onclick: function(e) { + onclick: lock(function(e) { e.redraw = false - }, + }), }) - } + }) } }) @@ -420,14 +467,12 @@ o.spec("route", function() { root.firstChild.dispatchEvent(e) // Wrapped to ensure no redraw fired - callAsync(function() { + return waitCycles(1).then(function() { o(onupdate.callCount).equals(0) - - done() }) }) - o("changes location on route.link", function() { + o("changes location on route.Link", function() { var e = $window.document.createEvent("MouseEvents") e.initEvent("click", true, true) @@ -435,17 +480,14 @@ o.spec("route", function() { $window.location.href = prefix + "/" route(root, "/", { "/" : { - view: function() { - return m("a", { - href: "/test", - oncreate: route.link - }) - } + view: lock(function() { + return m(route.Link, {href: "/test"}) + }) }, "/test" : { - view : function() { + view : lock(function() { return m("div") - } + }) } }) @@ -458,7 +500,7 @@ o.spec("route", function() { o($window.location.href).equals(env.protocol + "//" + (env.hostname === "/" ? "" : env.hostname) + slash + (prefix ? prefix + "/" : "") + "test") }) - o("passes options on route.link", function() { + o("passes options on route.Link", function() { var opts = {} var e = $window.document.createEvent("MouseEvents") @@ -467,17 +509,17 @@ o.spec("route", function() { route(root, "/", { "/" : { - view: function() { - return m("a", { + view: lock(function() { + return m(route.Link, { href: "/test", - oncreate: route.link(opts) + options: opts, }) - } + }) }, "/test" : { - view : function() { + view : lock(function() { return m("div") - } + }) } }) route.set = o.spy(route.set) @@ -488,17 +530,157 @@ o.spec("route", function() { o(route.set.args[2]).equals(opts) }) - o("accepts RouteResolver with onmatch that returns Component", function(done) { + o("route.Link can render without routes or dom access", function() { + $window = browserMock(env) + var render = coreRenderer($window) + route = apiRouter(null, null) + route.prefix = prefix + root = $window.document.body + + render(root, m(route.Link, {href: "/test", foo: "bar"}, "text")) + + o(root.childNodes.length).equals(1) + o(root.firstChild.nodeName).equals("A") + o(root.firstChild.href).equals(prefix + "/test") + o(root.firstChild.hasAttribute("aria-disabled")).equals(false) + o(root.firstChild.hasAttribute("disabled")).equals(false) + o(root.firstChild.attributes["foo"].value).equals("bar") + o(root.firstChild.childNodes.length).equals(1) + o(root.firstChild.firstChild.nodeName).equals("#text") + o(root.firstChild.firstChild.nodeValue).equals("text") + }) + + o("route.Link can render other tag without routes or dom access", function() { + $window = browserMock(env) + var render = coreRenderer($window) + route = apiRouter(null, null) + route.prefix = prefix + root = $window.document.body + + render(root, m(route.Link, {component: "button", href: "/test", foo: "bar"}, "text")) + + o(root.childNodes.length).equals(1) + o(root.firstChild.nodeName).equals("BUTTON") + o(root.firstChild.attributes["href"].value).equals(prefix + "/test") + o(root.firstChild.hasAttribute("aria-disabled")).equals(false) + o(root.firstChild.hasAttribute("disabled")).equals(false) + o(root.firstChild.attributes["foo"].value).equals("bar") + o(root.firstChild.childNodes.length).equals(1) + o(root.firstChild.firstChild.nodeName).equals("#text") + o(root.firstChild.firstChild.nodeValue).equals("text") + }) + + o("route.Link can render other selector without routes or dom access", function() { + $window = browserMock(env) + var render = coreRenderer($window) + route = apiRouter(null, null) + route.prefix = prefix + root = $window.document.body + + render(root, m(route.Link, {component: "button[href=/test]", foo: "bar"}, "text")) + + o(root.childNodes.length).equals(1) + o(root.firstChild.nodeName).equals("BUTTON") + o(root.firstChild.attributes["href"].value).equals(prefix + "/test") + o(root.firstChild.hasAttribute("aria-disabled")).equals(false) + o(root.firstChild.hasAttribute("disabled")).equals(false) + o(root.firstChild.attributes["foo"].value).equals("bar") + o(root.firstChild.childNodes.length).equals(1) + o(root.firstChild.firstChild.nodeName).equals("#text") + o(root.firstChild.firstChild.nodeValue).equals("text") + }) + + o("route.Link can render not disabled", function() { + $window = browserMock(env) + var render = coreRenderer($window) + route = apiRouter(null, null) + route.prefix = prefix + root = $window.document.body + + render(root, m(route.Link, {href: "/test", disabled: false, foo: "bar"}, "text")) + + o(root.childNodes.length).equals(1) + o(root.firstChild.nodeName).equals("A") + o(root.firstChild.href).equals(prefix + "/test") + o(root.firstChild.hasAttribute("aria-disabled")).equals(false) + o(root.firstChild.hasAttribute("disabled")).equals(false) + o(root.firstChild.attributes["foo"].value).equals("bar") + o(root.firstChild.childNodes.length).equals(1) + o(root.firstChild.firstChild.nodeName).equals("#text") + o(root.firstChild.firstChild.nodeValue).equals("text") + }) + + o("route.Link can render falsy disabled", function() { + $window = browserMock(env) + var render = coreRenderer($window) + route = apiRouter(null, null) + route.prefix = prefix + root = $window.document.body + + render(root, m(route.Link, {href: "/test", disabled: 0, foo: "bar"}, "text")) + + o(root.childNodes.length).equals(1) + o(root.firstChild.nodeName).equals("A") + o(root.firstChild.href).equals(prefix + "/test") + o(root.firstChild.hasAttribute("aria-disabled")).equals(false) + o(root.firstChild.hasAttribute("disabled")).equals(false) + o(root.firstChild.attributes["foo"].value).equals("bar") + o(root.firstChild.childNodes.length).equals(1) + o(root.firstChild.firstChild.nodeName).equals("#text") + o(root.firstChild.firstChild.nodeValue).equals("text") + }) + + o("route.Link can render disabled", function() { + $window = browserMock(env) + var render = coreRenderer($window) + route = apiRouter(null, null) + route.prefix = prefix + root = $window.document.body + + render(root, m(route.Link, {href: "/test", disabled: true, foo: "bar"}, "text")) + + o(root.childNodes.length).equals(1) + o(root.firstChild.nodeName).equals("A") + o(root.firstChild.href).equals("") + o(root.firstChild.attributes["aria-disabled"].value).equals("true") + o(root.firstChild.attributes["foo"].value).equals("bar") + o(root.firstChild.attributes["disabled"].value).equals("") + o(root.firstChild.childNodes.length).equals(1) + o(root.firstChild.firstChild.nodeName).equals("#text") + o(root.firstChild.firstChild.nodeValue).equals("text") + }) + + o("route.Link can render truthy disabled", function() { + $window = browserMock(env) + var render = coreRenderer($window) + route = apiRouter(null, null) + route.prefix = prefix + root = $window.document.body + + render(root, m(route.Link, {href: "/test", disabled: 1, foo: "bar"}, "text")) + + o(root.childNodes.length).equals(1) + o(root.firstChild.nodeName).equals("A") + o(root.firstChild.href).equals("") + o(root.firstChild.attributes["aria-disabled"].value).equals("true") + o(root.firstChild.attributes["foo"].value).equals("bar") + o(root.firstChild.attributes["disabled"].value).equals("") + o(root.firstChild.childNodes.length).equals(1) + o(root.firstChild.firstChild.nodeName).equals("#text") + o(root.firstChild.firstChild.nodeValue).equals("text") + }) + + o("accepts RouteResolver with onmatch that returns Component", function() { var matchCount = 0 var renderCount = 0 var Component = { - view: function() { + view: lock(function() { return m("span") - } + }) } var resolver = { - onmatch: function(args, requestedPath, route) { + onmatch: lock(function(args, requestedPath, route) { matchCount++ o(args.id).equals("abc") @@ -506,15 +688,15 @@ o.spec("route", function() { o(route).equals("/:id") o(this).equals(resolver) return Component - }, - render: function(vnode) { + }), + render: lock(function(vnode) { renderCount++ o(vnode.attrs.id).equals("abc") o(this).equals(resolver) return vnode - }, + }), } $window.location.href = prefix + "/abc" @@ -522,25 +704,84 @@ o.spec("route", function() { "/:id" : resolver }) - callAsync(function() { + return waitCycles(1).then(function() { o(matchCount).equals(1) o(renderCount).equals(1) o(root.firstChild.nodeName).equals("SPAN") - done() }) }) - o("accepts RouteResolver with onmatch that returns Promise", function(done) { + o("accepts RouteResolver with onmatch that returns route.SKIP", function() { + var match1Count = 0 + var match2Count = 0 + var render1 = o.spy() + var render2Count = 0 + var Component = { + view: lock(function() { + return m("span") + }) + } + + var resolver1 = { + onmatch: lock(function(args, requestedPath, key) { + match1Count++ + + o(args.id1).equals("abc") + o(requestedPath).equals("/abc") + o(key).equals("/:id1") + o(this).equals(resolver1) + return route.SKIP + }), + render: lock(render1), + } + + var resolver2 = { + onmatch: function(args, requestedPath, key) { + match2Count++ + + o(args.id2).equals("abc") + o(requestedPath).equals("/abc") + o(key).equals("/:id2") + o(this).equals(resolver2) + return Component + }, + render: function(vnode) { + render2Count++ + + o(vnode.attrs.id2).equals("abc") + o(this).equals(resolver2) + o(render1.callCount).equals(0) + + return vnode + }, + } + + $window.location.href = prefix + "/abc" + route(root, "/abc", { + "/:id1" : resolver1, + "/:id2" : resolver2 + }) + + return waitCycles(4).then(function() { + o(match1Count).equals(1) + o(match2Count).equals(1) + o(render2Count).equals(1) + o(render1.callCount).equals(0) + o(root.firstChild.nodeName).equals("SPAN") + }) + }) + + o("accepts RouteResolver with onmatch that returns Promise", function() { var matchCount = 0 var renderCount = 0 var Component = { - view: function() { + view: lock(function() { return m("span") - } + }) } var resolver = { - onmatch: function(args, requestedPath, route) { + onmatch: lock(function(args, requestedPath, route) { matchCount++ o(args.id).equals("abc") @@ -548,15 +789,15 @@ o.spec("route", function() { o(route).equals("/:id") o(this).equals(resolver) return Promise.resolve(Component) - }, - render: function(vnode) { + }), + render: lock(function(vnode) { renderCount++ o(vnode.attrs.id).equals("abc") o(this).equals(resolver) return vnode - }, + }), } $window.location.href = prefix + "/abc" @@ -564,20 +805,19 @@ o.spec("route", function() { "/:id" : resolver }) - callAsync(function() { + return waitCycles(10).then(function() { o(matchCount).equals(1) o(renderCount).equals(1) o(root.firstChild.nodeName).equals("SPAN") - done() }) }) - o("accepts RouteResolver with onmatch that returns Promise", function(done) { + o("accepts RouteResolver with onmatch that returns Promise", function() { var matchCount = 0 var renderCount = 0 var resolver = { - onmatch: function(args, requestedPath, route) { + onmatch: lock(function(args, requestedPath, route) { matchCount++ o(args.id).equals("abc") @@ -585,15 +825,15 @@ o.spec("route", function() { o(route).equals("/:id") o(this).equals(resolver) return Promise.resolve() - }, - render: function(vnode) { + }), + render: lock(function(vnode) { renderCount++ o(vnode.attrs.id).equals("abc") o(this).equals(resolver) return vnode - }, + }), } $window.location.href = prefix + "/abc" @@ -601,20 +841,19 @@ o.spec("route", function() { "/:id" : resolver }) - callAsync(function() { + return waitCycles(2).then(function() { o(matchCount).equals(1) o(renderCount).equals(1) o(root.firstChild.nodeName).equals("DIV") - done() }) }) - o("accepts RouteResolver with onmatch that returns Promise", function(done) { + o("accepts RouteResolver with onmatch that returns Promise", function() { var matchCount = 0 var renderCount = 0 var resolver = { - onmatch: function(args, requestedPath, route) { + onmatch: lock(function(args, requestedPath, route) { matchCount++ o(args.id).equals("abc") @@ -622,15 +861,15 @@ o.spec("route", function() { o(route).equals("/:id") o(this).equals(resolver) return Promise.resolve([]) - }, - render: function(vnode) { + }), + render: lock(function(vnode) { renderCount++ o(vnode.attrs.id).equals("abc") o(this).equals(resolver) return vnode - }, + }), } $window.location.href = prefix + "/abc" @@ -638,28 +877,27 @@ o.spec("route", function() { "/:id" : resolver }) - callAsync(function() { + return waitCycles(2).then(function() { o(matchCount).equals(1) o(renderCount).equals(1) o(root.firstChild.nodeName).equals("DIV") - done() }) }) - o("accepts RouteResolver with onmatch that returns rejected Promise", function(done) { + o("accepts RouteResolver with onmatch that returns rejected Promise", function() { var matchCount = 0 var renderCount = 0 var spy = o.spy() var resolver = { - onmatch: function() { + onmatch: lock(function() { matchCount++ return Promise.reject(new Error("error")) - }, - render: function(vnode) { + }), + render: lock(function(vnode) { renderCount++ return vnode - }, + }), } $window.location.href = prefix + "/test/1" @@ -668,28 +906,25 @@ o.spec("route", function() { "/test/:id" : resolver }) - callAsync(function() { - callAsync(function() { - o(matchCount).equals(1) - o(renderCount).equals(0) - o(spy.callCount).equals(1) - done() - }) + return waitCycles(3).then(function() { + o(matchCount).equals(1) + o(renderCount).equals(0) + o(spy.callCount).equals(1) }) }) - 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() { + view: lock(function() { return m("div") - } + }) } $window.location.href = prefix + "/abc" route(root, "/abc", { "/:id" : { - onmatch: function(args, requestedPath, route) { + onmatch: lock(function(args, requestedPath, route) { matchCount++ o(args.id).equals("abc") @@ -697,38 +932,34 @@ o.spec("route", function() { o(route).equals("/:id") return Component - }, + }), }, }) - callAsync(function() { + return waitCycles(2).then(function() { o(matchCount).equals(1) o(root.firstChild.nodeName).equals("DIV") - done() }) }) - o("changing `key` param resets the component", function(done){ + o("changing `key` param resets the component", function(){ var oninit = o.spy() var Component = { oninit: oninit, - view: function() { + view: lock(function() { return m("div") - } + }) } $window.location.href = prefix + "/abc" route(root, "/abc", { "/:key": Component, }) - callAsync(function() { + return waitCycles(1).then(function() { o(oninit.callCount).equals(1) route.set("/def") - callAsync(function() { + return waitCycles(1).then(function() { throttleMock.fire() - o(oninit.callCount).equals(2) - - done() }) }) }) @@ -736,21 +967,21 @@ o.spec("route", function() { o("accepts RouteResolver without `onmatch` method as payload", function() { var renderCount = 0 var Component = { - view: function() { + view: lock(function() { return m("div") - } + }) } $window.location.href = prefix + "/abc" route(root, "/abc", { "/:id" : { - render: function(vnode) { + render: lock(function(vnode) { renderCount++ o(vnode.attrs.id).equals("abc") return m(Component) - }, + }), }, }) @@ -758,18 +989,18 @@ o.spec("route", function() { o(renderCount).equals(1) }) - o("RouteResolver `render` does not have component semantics", function(done) { + o("RouteResolver `render` does not have component semantics", function() { $window.location.href = prefix + "/a" route(root, "/a", { "/a" : { - render: function() { + render: lock(function() { return m("div", m("p")) - }, + }), }, "/b" : { - render: function() { + render: lock(function() { return m("div", m("a")) - }, + }), }, }) @@ -780,40 +1011,38 @@ o.spec("route", function() { route.set("/b") - callAsync(function() { + return waitCycles(1).then(function() { throttleMock.fire() o(root.firstChild).equals(dom) o(root.firstChild.firstChild).notEquals(child) - - done() }) }) - 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 = { - view: function() { + view: lock(function() { return m("div") - } + }) } $window.location.href = prefix + "/" route(root, "/", { "/" : { - onmatch: function() { + onmatch: lock(function() { matchCount++ return Component - }, - render: function(vnode) { + }), + render: lock(function(vnode) { renderCount++ return vnode - }, + }), }, }) - callAsync(function() { + return waitCycles(1).then(function() { o(matchCount).equals(1) o(renderCount).equals(1) @@ -822,34 +1051,32 @@ o.spec("route", function() { o(matchCount).equals(1) o(renderCount).equals(2) - - done() }) }) - o("calls onmatch and view correct number of times when not onmatch returns undefined", function(done) { + o("calls onmatch and view correct number of times when not onmatch returns undefined", function() { var matchCount = 0 var renderCount = 0 var Component = { - view: function() { + view: lock(function() { return m("div") - } + }) } $window.location.href = prefix + "/" route(root, "/", { "/" : { - onmatch: function() { + onmatch: lock(function() { matchCount++ - }, - render: function() { + }), + render: lock(function() { renderCount++ return {tag: Component} - }, + }), }, }) - callAsync(function() { + return waitCycles(2).then(function() { o(matchCount).equals(1) o(renderCount).equals(1) @@ -858,39 +1085,35 @@ o.spec("route", function() { o(matchCount).equals(1) o(renderCount).equals(2) - - done() }) }) - o("onmatch can redirect to another route", function(done) { + o("onmatch can redirect to another route", function() { var redirected = false var render = o.spy() $window.location.href = prefix + "/a" route(root, "/a", { "/a" : { - onmatch: function() { + onmatch: lock(function() { route.set("/b") - }, - render: render + }), + render: lock(render) }, "/b" : { - view: function() { + view: lock(function() { redirected = true - } + }) } }) - callAsync(function() { + return waitCycles(2).then(function() { o(render.callCount).equals(0) o(redirected).equals(true) - - done() }) }) - o("onmatch can redirect to another route that has RouteResolver w/ only onmatch", function(done) { + o("onmatch can redirect to another route that has RouteResolver w/ only onmatch", function() { var redirected = false var render = o.spy() var view = o.spy(function() {return m("div")}) @@ -898,99 +1121,55 @@ o.spec("route", function() { $window.location.href = prefix + "/a" route(root, "/a", { "/a" : { - onmatch: function() { + onmatch: lock(function() { route.set("/b", {}, {state: {a: 5}}) - }, - render: render + }), + render: lock(render) }, "/b" : { - onmatch: function() { + onmatch: lock(function() { redirected = true - return {view: view} - } + return {view: lock(view)} + }) } }) - callAsync(function() { - callAsync(function() { - o(render.callCount).equals(0) - o(redirected).equals(true) - o(view.callCount).equals(1) - o(root.childNodes.length).equals(1) - o(root.firstChild.nodeName).equals("DIV") - o($window.history.state).deepEquals({a: 5}) - - done() - }) - }) - }) - - o("onmatch can redirect to another route that has RouteResolver w/ only render", function(done) { - var redirected = false - var render = o.spy() - - $window.location.href = prefix + "/a" - route(root, "/a", { - "/a" : { - onmatch: function() { - route.set("/b") - }, - render: render - }, - "/b" : { - render: function(){ - redirected = true - } - } - }) - - callAsync(function() { + return waitCycles(3).then(function() { o(render.callCount).equals(0) o(redirected).equals(true) - - done() + o(view.callCount).equals(1) + o(root.childNodes.length).equals(1) + o(root.firstChild.nodeName).equals("DIV") + o($window.history.state).deepEquals({a: 5}) }) }) - o("onmatch can redirect to another route that has RouteResolver whose onmatch resolves asynchronously", function(done) { + o("onmatch can redirect to another route that has RouteResolver w/ only render", function() { var redirected = false var render = o.spy() - var view = o.spy() $window.location.href = prefix + "/a" route(root, "/a", { "/a" : { - onmatch: function() { + onmatch: lock(function() { route.set("/b") - }, - render: render + }), + render: lock(render) }, "/b" : { - onmatch: function() { + render: lock(function(){ redirected = true - return new Promise(function(fulfill){ - callAsync(function(){ - fulfill({view: view}) - }) - }) - } + }) } }) - callAsync(function() { - callAsync(function() { - callAsync(function() { - o(render.callCount).equals(0) - o(redirected).equals(true) - o(view.callCount).equals(1) - - done() - }) - }) + return waitCycles(2).then(function() { + o(render.callCount).equals(0) + o(redirected).equals(true) }) }) - o("onmatch can redirect to another route asynchronously", function(done) { + o("onmatch can redirect to another route that has RouteResolver whose onmatch resolves asynchronously", function() { var redirected = false var render = o.spy() var view = o.spy() @@ -998,34 +1177,58 @@ o.spec("route", function() { $window.location.href = prefix + "/a" route(root, "/a", { "/a" : { - onmatch: function() { - callAsync(function() {route.set("/b")}) - return new Promise(function() {}) - }, - render: render + onmatch: lock(function() { + route.set("/b") + }), + render: lock(render) }, "/b" : { - onmatch: function() { + onmatch: lock(function() { redirected = true - return {view: view} - } + return waitCycles(1).then(function(){ + return {view: view} + }) + }) } }) - callAsync(function() { - callAsync(function() { - callAsync(function() { - o(render.callCount).equals(0) - o(redirected).equals(true) - o(view.callCount).equals(1) - - done() - }) - }) + return waitCycles(6).then(function() { + o(render.callCount).equals(0) + o(redirected).equals(true) + o(view.callCount).equals(1) }) }) - o("onmatch can redirect w/ window.history.back()", function(done) { + o("onmatch can redirect to another route asynchronously", function() { + var redirected = false + var render = o.spy() + var view = o.spy() + + $window.location.href = prefix + "/a" + route(root, "/a", { + "/a" : { + onmatch: lock(function() { + waitCycles(1).then(function() {route.set("/b")}) + return new Promise(function() {}) + }), + render: lock(render) + }, + "/b" : { + onmatch: lock(function() { + redirected = true + return {view: lock(view)} + }) + } + }) + + return waitCycles(5).then(function() { + o(render.callCount).equals(0) + o(redirected).equals(true) + o(view.callCount).equals(1) + }) + }) + + o("onmatch can redirect w/ window.history.back()", function() { var render = o.spy() var component = {view: o.spy()} @@ -1033,130 +1236,115 @@ o.spec("route", function() { $window.location.href = prefix + "/a" route(root, "/a", { "/a" : { - onmatch: function() { + onmatch: lock(function() { return component - }, - render: function(vnode) { + }), + render: lock(function(vnode) { return vnode - } + }) }, "/b" : { - onmatch: function() { + onmatch: lock(function() { $window.history.back() return new Promise(function() {}) - }, - render: render + }), + render: lock(render) } }) - callAsync(function() { + return waitCycles(2).then(function() { throttleMock.fire() route.set("/b") - callAsync(function() { - callAsync(function() { - callAsync(function() { - throttleMock.fire() + o(render.callCount).equals(0) + o(component.view.callCount).equals(1) - o(render.callCount).equals(0) - o(component.view.callCount).equals(2) + return waitCycles(4).then(function() { + throttleMock.fire() - done() - }) + o(render.callCount).equals(0) + o(component.view.callCount).equals(2) + }) + }) + }) + + o("onmatch can redirect to a non-existent route that defaults to a RouteResolver w/ onmatch", function() { + var redirected = false + var render = o.spy() + + $window.location.href = prefix + "/a" + route(root, "/b", { + "/a" : { + onmatch: lock(function() { + route.set("/c") + }), + render: lock(render) + }, + "/b" : { + onmatch: lock(function(){ + redirected = true + return {view: lock(function() {})} }) - }) + } + }) + + return waitCycles(3).then(function() { + o(render.callCount).equals(0) + o(redirected).equals(true) }) }) - o("onmatch can redirect to a non-existent route that defaults to a RouteResolver w/ onmatch", function(done) { + o("onmatch can redirect to a non-existent route that defaults to a RouteResolver w/ render", function() { var redirected = false var render = o.spy() $window.location.href = prefix + "/a" route(root, "/b", { "/a" : { - onmatch: function() { + onmatch: lock(function() { route.set("/c") - }, - render: render + }), + render: lock(render) }, "/b" : { - onmatch: function(){ + render: lock(function(){ redirected = true - return {view: function() {}} - } + }) } }) - callAsync(function() { - callAsync(function() { - o(render.callCount).equals(0) - o(redirected).equals(true) - - done() - }) + return waitCycles(3).then(function() { + o(render.callCount).equals(0) + o(redirected).equals(true) }) }) - o("onmatch can redirect to a non-existent route that defaults to a RouteResolver w/ render", function(done) { + o("onmatch can redirect to a non-existent route that defaults to a component", function() { var redirected = false var render = o.spy() $window.location.href = prefix + "/a" route(root, "/b", { "/a" : { - onmatch: function() { + onmatch: lock(function() { route.set("/c") - }, - render: render + }), + render: lock(render) }, "/b" : { - render: function(){ + view: lock(function(){ redirected = true - } + }) } }) - callAsync(function() { - callAsync(function() { - o(render.callCount).equals(0) - o(redirected).equals(true) - - done() - }) + return waitCycles(3).then(function() { + o(render.callCount).equals(0) + o(redirected).equals(true) }) }) - o("onmatch can redirect to a non-existent route that defaults to a component", function(done) { - var redirected = false - var render = o.spy() - - $window.location.href = prefix + "/a" - route(root, "/b", { - "/a" : { - onmatch: function() { - route.set("/c") - }, - render: render - }, - "/b" : { - view: function(){ - redirected = true - } - } - }) - - callAsync(function() { - callAsync(function() { - o(render.callCount).equals(0) - o(redirected).equals(true) - - done() - }) - }) - }) - - o("the previous view redraws while onmatch resolution is pending (#1268)", function(done) { + o("the previous view redraws while onmatch resolution is pending (#1268)", function() { var view = o.spy() var onmatch = o.spy(function() { return new Promise(function() {}) @@ -1164,9 +1352,9 @@ o.spec("route", function() { $window.location.href = prefix + "/a" route(root, "/", { - "/a": {view: view}, - "/b": {onmatch: onmatch}, - "/": {view: function() {}} + "/a": {view: lock(view)}, + "/b": {onmatch: lock(onmatch)}, + "/": {view: lock(function() {})} }) o(view.callCount).equals(1) @@ -1174,7 +1362,7 @@ o.spec("route", function() { route.set("/b") - callAsync(function() { + return waitCycles(1).then(function() { o(view.callCount).equals(1) o(onmatch.callCount).equals(1) @@ -1183,8 +1371,6 @@ o.spec("route", function() { o(view.callCount).equals(2) o(onmatch.callCount).equals(1) - - done() }) }) @@ -1192,49 +1378,44 @@ o.spec("route", function() { var renderA = o.spy() var renderB = o.spy() var onmatchA = o.spy(function(){ - return new Promise(function(fulfill) { - setTimeout(function(){ - fulfill() - }, 10) - }) + return waitCycles(3) }) $window.location.href = prefix + "/a" route(root, "/a", { "/a": { - onmatch: onmatchA, - render: renderA + onmatch: lock(onmatchA), + render: lock(renderA) }, "/b": { - onmatch: function(){ + onmatch: lock(function(){ var p = new Promise(function(fulfill) { o(onmatchA.callCount).equals(1) o(renderA.callCount).equals(0) o(renderB.callCount).equals(0) - setTimeout(function(){ + waitCycles(3).then(function(){ o(onmatchA.callCount).equals(1) o(renderA.callCount).equals(0) o(renderB.callCount).equals(0) fulfill() - - p.then(function(){ - o(onmatchA.callCount).equals(1) - o(renderA.callCount).equals(0) - o(renderB.callCount).equals(1) - - done() - }) - }, 20) + return p + }).then(function(){ + return waitCycles(1) + }).then(function(){ + o(onmatchA.callCount).equals(1) + o(renderA.callCount).equals(0) + o(renderB.callCount).equals(1) + }).then(done, done) }) return p - }, - render: renderB + }), + render: lock(renderB) } }) - callAsync(function() { + waitCycles(1).then(lock(function() { o(onmatchA.callCount).equals(1) o(renderA.callCount).equals(0) o(renderB.callCount).equals(0) @@ -1242,22 +1423,22 @@ o.spec("route", function() { o(onmatchA.callCount).equals(1) o(renderA.callCount).equals(0) o(renderB.callCount).equals(0) - }) + })) }) - o("m.route.set(m.route.get()) re-runs the resolution logic (#1180)", function(done){ + o("m.route.set(m.route.get()) re-runs the resolution logic (#1180)", function(){ var onmatch = o.spy() var render = o.spy(function() {return m("div")}) $window.location.href = prefix + "/" route(root, "/", { "/": { - onmatch: onmatch, - render: render + onmatch: lock(onmatch), + render: lock(render) } }) - callAsync(function() { + return waitCycles(1).then(function() { throttleMock.fire() o(onmatch.callCount).equals(1) @@ -1265,28 +1446,24 @@ o.spec("route", function() { route.set(route.get()) - callAsync(function() { - callAsync(function() { - throttleMock.fire() + return waitCycles(2).then(function() { + throttleMock.fire() - o(onmatch.callCount).equals(2) - o(render.callCount).equals(2) - - done() - }) + o(onmatch.callCount).equals(2) + o(render.callCount).equals(2) }) }) }) - o("m.route.get() returns the last fully resolved route (#1276)", function(done){ + o("m.route.get() returns the last fully resolved route (#1276)", function(){ $window.location.href = prefix + "/" route(root, "/", { - "/": {view: function() {}}, + "/": {view: lock(function() {})}, "/2": { - onmatch: function() { + onmatch: lock(function() { return new Promise(function() {}) - } + }) } }) @@ -1295,142 +1472,130 @@ o.spec("route", function() { route.set("/2") - callAsync(function() { + return waitCycles(1).then(function() { o(route.get()).equals("/") - done() }) }) - o("routing with RouteResolver works more than once", function(done) { + o("routing with RouteResolver works more than once", function() { $window.location.href = prefix + "/a" route(root, "/a", { "/a": { - render: function() { + render: lock(function() { return m("a", "a") - } + }) }, "/b": { - render: function() { + render: lock(function() { return m("b", "b") - } + }) } }) route.set("/b") - callAsync(function() { + return waitCycles(1).then(function() { throttleMock.fire() o(root.firstChild.nodeName).equals("B") route.set("/a") - callAsync(function() { + return waitCycles(1).then(function() { throttleMock.fire() o(root.firstChild.nodeName).equals("A") - - done() }) }) }) - o("calling route.set invalidates pending onmatch resolution", function(done) { + o("calling route.set invalidates pending onmatch resolution", function() { var rendered = false var resolved $window.location.href = prefix + "/a" route(root, "/a", { "/a": { - onmatch: function() { - return new Promise(function(resolve) { - callAsync(function() { - callAsync(function() { - resolve({view: function() {rendered = true}}) - }) - }) + onmatch: lock(function() { + return waitCycles(2).then(function() { + return {view: lock(function() {rendered = true})} }) - }, - render: function() { + }), + render: lock(function() { rendered = true resolved = "a" - } + }) }, "/b": { - view: function() { + view: lock(function() { resolved = "b" - } + }) } }) route.set("/b") - callAsync(function() { + return waitCycles(1).then(function() { o(rendered).equals(false) o(resolved).equals("b") - callAsync(function() { + return waitCycles(1).then(function() { o(rendered).equals(false) o(resolved).equals("b") - done() }) }) }) - o("route changes activate onbeforeremove", function(done) { + o("route changes activate onbeforeremove", function() { var spy = o.spy() $window.location.href = prefix + "/a" route(root, "/a", { "/a": { - onbeforeremove: spy, - view: function() {} + onbeforeremove: lock(spy), + view: lock(function() {}) }, "/b": { - view: function() {} + view: lock(function() {}) } }) route.set("/b") // setting the route is asynchronous - callAsync(function() { + return waitCycles(1).then(function() { throttleMock.fire() o(spy.callCount).equals(1) - - done() }) }) - o("asynchronous route.set in onmatch works", function(done) { + o("asynchronous route.set in onmatch works", function() { var rendered = false, resolved route(root, "/a", { "/a": { - onmatch: function() { - return Promise.resolve().then(function() { + onmatch: lock(function() { + return Promise.resolve().then(lock(function() { route.set("/b") - }) - }, - render: function() { + })) + }), + render: lock(function() { rendered = true resolved = "a" - } + }) }, "/b": { - view: function() { + view: lock(function() { resolved = "b" - } + }) }, }) - callAsync(function() { // tick for popstate for /a - callAsync(function() { // tick for promise in onmatch - callAsync(function() { // tick for onpopstate for /b - o(rendered).equals(false) - o(resolved).equals("b") - - done() - }) - }) + // tick for popstate for /a + // tick for onmatch + // tick for promise in onmatch + // tick for onpopstate for /b + return waitCycles(4).then(function() { + o(rendered).equals(false) + o(resolved).equals("b") }) }) @@ -1438,7 +1603,7 @@ o.spec("route", function() { var i = 0 $window.location.href = prefix + "/" route(root, "/", { - "/": {view: function() {i++}} + "/": {view: lock(function() {i++})} }) var before = i @@ -1455,27 +1620,25 @@ o.spec("route", function() { o(i).equals(2) }) - o("m.route.param is available outside of route handlers", function(done) { + o("m.route.param is available outside of route handlers", function() { $window.location.href = prefix + "/" route(root, "/1", { "/:id" : { - view : function() { + view : lock(function() { o(route.param("id")).equals("1") return m("div") - } + }) } }) o(route.param("id")).equals(undefined); o(route.param()).deepEquals(undefined); - callAsync(function() { + return waitCycles(1).then(function() { o(route.param("id")).equals("1") o(route.param()).deepEquals({id:"1"}) - - done() }) }) }) diff --git a/api/tests/test-routerGetSet.js b/api/tests/test-routerGetSet.js index cd23ae20..6a7121b2 100644 --- a/api/tests/test-routerGetSet.js +++ b/api/tests/test-routerGetSet.js @@ -25,7 +25,7 @@ o.spec("route.get/route.set", function() { mountRedraw = apiMountRedraw(coreRenderer($window), throttleMock.schedule, console) route = apiRouter($window, mountRedraw) - route.prefix(prefix) + route.prefix = prefix }) o.afterEach(function() { diff --git a/docs/api.md b/docs/api.md index cc73fae8..536931fc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -57,18 +57,18 @@ m.route.set("/home") var currentRoute = m.route.get() ``` -#### m.route.prefix(prefix) - [docs](route.md#mrouteprefix) +#### m.route.prefix = prefix - [docs](route.md#mrouteprefix) -Call this before `m.route()` +Invoke this before `m.route()` to change the routing prefix. ```javascript -m.route.prefix("#!") +m.route.prefix = "#!" ``` -#### m.route.link() - [docs](route.md#mroutelink) +#### m(m.route.Link, ...) - [docs](route.md#mroutelink) ```javascript -m("a[href='/Home']", {oncreate: m.route.link}, "Go to home page") +m(m.route.Link, {href: "/Home"}, "Go to home page") ``` --- diff --git a/docs/autoredraw.md b/docs/autoredraw.md index 6fcc48de..a7a891d7 100644 --- a/docs/autoredraw.md +++ b/docs/autoredraw.md @@ -63,14 +63,14 @@ m.request("/api/v1/users", {background: true}).then(function() { ### After route changes -Mithril automatically redraws after [`m.route.set()`](route.md#mrouteset) calls (or route changes via links that use [`m.route.link`](route.md#mroutelink) +Mithril automatically redraws after [`m.route.set()`](route.md#mrouteset) calls and after route changes via links using [`m.route.Link`](route.md#mroutelink). ```javascript var RoutedComponent = { view: function() { return [ // a redraw happens asynchronously after the route changes - m("a", {href: "/", oncreate: m.route.link}), + m(m.route.Link, {href: "/"}), m("div", { onclick: function() { m.route.set("/") diff --git a/docs/change-log.md b/docs/change-log.md index e8022755..a75f0a63 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -61,6 +61,18 @@ - The `.schedule`, `.unschedule`, and `.render` properties of the former `redrawService` are all removed. - If you want to know how to work around it, look at the call to `mount` in Mithril's source for `m.route`. That should help you in finding ways around the removed feature. (It doesn't take that much more code.) - api: `m.version` has been removed. If you really need the version for whatever reason, just read the `version` field of `mithril/package.json` directly. ([#2466](https://github.com/MithrilJS/mithril.js/pull/2466) [@isiahmeadows](https://github.com/isiahmeadows)) +- route: `m.route.prefix(...)` is now `m.route.prefix = ...`. ([#2469](https://github.com/MithrilJS/mithril.js/pull/2469) [@isiahmeadows](https://github.com/isiahmeadows)) + - This is a fully fledged property, so you can not only write to it, but you can also read from it. + - This aligns better with user intuition. +- route: `m.route.link` function removed in favor of `m.route.Link` component. ([#2469](https://github.com/MithrilJS/mithril.js/pull/2469) [@isiahmeadows](https://github.com/isiahmeadows)) + - An optional `options` object is accepted as an attribute. This was initially targeting the old `m.route.link` function and was transferred to this. ([#1930](https://github.com/MithrilJS/mithril.js/pull/1930)) + - The new component handles many more edge cases around user interaction, including accessibility. + - Link navigation can be disabled and cancelled. + - Link targets can be trivially changed. +- API: Full DOM no longer required to execute `require("mithril")`. You just need to set the necessary globals to *something*, even if `null` or `undefined`, so they can be properly used. ([#2469](https://github.com/MithrilJS/mithril.js/pull/2469) [@isiahmeadows](https://github.com/isiahmeadows)) + - This enables isomorphic use of `m.route.Link` and `m.route.prefix`. + - This enables isomorphic use of `m.request`, provided the `background: true` option is set and that an `XMLHttpRequest` polyfill is included as necessary. + - Note that methods requiring DOM operations will still throw errors, such as `m.render(...)`, `m.redraw()`, and `m.route(...)`. #### News @@ -68,7 +80,6 @@ - Mithril now only officially supports IE11, Firefox ESR, and the last two versions of Chrome/FF/Edge/Safari. ([#2296](https://github.com/MithrilJS/mithril.js/pull/2296)) - API: Introduction of `m.redraw.sync()` ([#1592](https://github.com/MithrilJS/mithril.js/pull/1592)) - API: Event handlers may also be objects with `handleEvent` methods ([#1949](https://github.com/MithrilJS/mithril.js/pull/1949), [#2222](https://github.com/MithrilJS/mithril.js/pull/2222)). -- API: `m.route.link` accepts an optional `options` object ([#1930](https://github.com/MithrilJS/mithril.js/pull/1930)) - API: `m.request` better error message on JSON parse error - ([#2195](https://github.com/MithrilJS/mithril.js/pull/2195), [@codeclown](https://github.com/codeclown)) - API: `m.request` supports `timeout` as attr - ([#1966](https://github.com/MithrilJS/mithril.js/pull/1966)) - API: `m.request` supports `responseType` as attr - ([#2193](https://github.com/MithrilJS/mithril.js/pull/2193)) @@ -88,6 +99,7 @@ - API: `m.buildPathname` and `m.parsePathname` added. ([#2361](https://github.com/MithrilJS/mithril.js/pull/2361)) - route: Use `m.mount(root, null)` to unsubscribe and clean up after a `m.route(root, ...)` call. ([#2453](https://github.com/MithrilJS/mithril.js/pull/2453)) - render: new `redraw` parameter exposed any time a child event handler is used ([#2458](https://github.com/MithrilJS/mithril.js/pull/2458) [@isiahmeadows](https://github.com/isiahmeadows)) +- route: `m.route.SKIP` can be returned from route resolvers to skip to the next route ([#2469](https://github.com/MithrilJS/mithril.js/pull/2469) [@isiahmeadows](https://github.com/isiahmeadows)) #### Bug fixes diff --git a/docs/hyperscript.md b/docs/hyperscript.md index 7bf438d4..79d8eb96 100644 --- a/docs/hyperscript.md +++ b/docs/hyperscript.md @@ -44,7 +44,7 @@ You can also use an HTML-like syntax called [JSX](jsx.md), using Babel to conver Argument | Type | Required | Description ------------ | ------------------------------------------ | -------- | --- -`selector` | `String|Object` | Yes | A CSS selector or a [component](components.md) +`selector` | `String|Object|Function` | Yes | A CSS selector or a [component](components.md) `attrs` | `Object` | No | HTML attributes or element properties `children` | `Array|String|Number|Boolean` | No | Child [vnodes](vnodes.md#structure). Can be written as [splat arguments](signatures.md#splats) **returns** | `Vnode` | | A [vnode](vnodes.md#structure) diff --git a/docs/index.md b/docs/index.md index 4fbffd82..d1a98478 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,7 +18,7 @@ It's small (< 10kb gzip), fast and provides routing and XHR utilities out of the
Download size
- Mithril (9.4kb) + Mithril (9.5kb)
Vue + Vue-Router + Vuex + fetch (40kb)
diff --git a/docs/route.md b/docs/route.md index 4b24d94b..de76b688 100644 --- a/docs/route.md +++ b/docs/route.md @@ -6,8 +6,9 @@ - [m.route.set](#mrouteset) - [m.route.get](#mrouteget) - [m.route.prefix](#mrouteprefix) - - [m.route.link](#mroutelink) + - [m.route.Link](#mroutelink) - [m.route.param](#mrouteparam) + - [m.route.SKIP](#mrouteskip) - [RouteResolver](#routeresolver) - [routeResolver.onmatch](#routeresolveronmatch) - [routeResolver.render](#routeresolverrender) @@ -24,6 +25,8 @@ - [Authentication](#authentication) - [Preloading data](#preloading-data) - [Code splitting](#code-splitting) + - [Typed routes](#typed-routes) + - [Hidden routes](#typed-routes) - [Third-party integration](#third-party-integration) --- @@ -92,6 +95,7 @@ m.route(document.body, { }) m.route.set('/article/:articleid', {articleid: 1}) ``` + ##### m.route.get 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). @@ -106,41 +110,107 @@ Argument | Type | Required | Description Defines a router prefix. The router prefix is a fragment of the URL that dictates the underlying [strategy](#routing-strategies) used by the router. -`m.route.prefix(prefix)` +`m.route.prefix = prefix` Argument | Type | Required | Description ----------------- | --------- | -------- | --- `prefix` | `String` | Yes | The prefix that controls the underlying [routing strategy](#routing-strategies) used by Mithril. -**returns** | | | Returns `undefined` -##### m.route.link +This is a simple property, so you can both read it and write to it. -This function can be used as the `oncreate` (and `onupdate`) hook in a `m("a")` vnode: +##### m.route.Link -```JS -m("a[href=/]", {oncreate: m.route.link}) +This component can create a dynamic routable link: + +```javascript +m(m.route.Link, {href: "/test"}) ``` -Using `m.route.link` as a `oncreate` hook causes the link to behave as a router link (i.e. it navigates to the route specified in `href`, instead of navigating away from the current page to the URL specified in `href`. +Using `m.route.Link` causes the link to behave as a router link - clicking it navigates to the route specified in `href`, instead of navigating away from the current page to the URL specified in `href`. -If the `href` attribute is not static, the `onupdate` hook must also be set: +You can also set the `options` passed to `m.route.set` when the link is clicked by passing the `options` attribute: -```JS -m("a", {href: someVariable, oncreate: m.route.link, onupdate: m.route.link}) +```javascript +m(m.route.Link, {href: "/test", options: {replace: true}}) + +// You can even use URL templates this way, the same way you can with +// `m.route.set`. +m(m.route.Link, {href: "/edit/:id", options: {params: {id: item.id}}}) ``` -`m.route.link` can also set the `options` passed to `m.route.set` when the link is clicked by calling the function in the lifecycle methods: +You can pass other attributes, too, and you can also specify the tag name used. -```JS -m("a[href=/]", {oncreate: m.route.link({replace: true})}) +```javascript +m(m.route.Link, { + // Any hyperscript selector is valid here - it's literally passed as the + // first parameter to `m`. + component: "span", + options: {replace: true}, + href: "/test", + disabled: false, + class: "nav-link", + "data-foo": 1, + // and other attributes +}, "link name") ``` -`m.route.link(args)` +Magic attributes used by this component (except `href` and `disabled`) *are* removed while proxying, so you won't have an odd `component="span"` or `options="[object Object]"` attribute show up in your link's DOM node. The above component renders to this hyperscript, assuming the prefix is the default `#!`: -Argument | Type | Required | Description ------------------ | ---------------| -------- | --- -`args` | `Vnode|Object` | Yes | This method is meant to be used as or in conjunction with an `` [vnode](vnodes.md)'s [`oncreate` and `onupdate` hooks](lifecycle-methods.md) -**returns** | `function` | | Returns the onclick handler function for the component +```javascript +m("span", { + href: "#!/test", + onclick: function(e) { + // ... + }, + disabled: false, // Only if you specify it + class: "nav-link", + "data-foo": 1, + // and other attributes +}) +``` + +You can also prevent navigation by, in an `onclick` handler, invoking `ev.preventDefault()` or returning `false`. This is the same way you block other events, so it's pretty natural. + +```javascript +m(m.route.Link, { + href: "/test", + onclick: function(e) { + // Do things... + if (notReady()) e.preventDefault() + } +}, "link name") +``` + +This supports full accessibility for both `a` and `button`, via a `disabled` attribute. This ensures [no `href` attribute or `onclick` handler is set](https://css-tricks.com/how-to-disable-links/) and that an `"aria-disabled": "true"` attribute *is* set. If you are passing an `onclick` handler already, that's dropped. (You can work around this by adding it directly in a [lifecycle hook](lifecycle.md).) The `disabled` attribute is itself proxied to the element or component, so you can disable routed `