diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..48873c87 --- /dev/null +++ b/.npmignore @@ -0,0 +1,20 @@ +# Configuration files +.deploy.env +.editorconfig +.eslintrc.js +.gitattributes +.gitignore +.travis.yml + +# Tests +test-utils/ +tests/ + +# Documentation +docs/ +examples/ +CONTRIBUTING.md + +# Browser stub (use index.js w/ a bundler or mithril.js w/o one instead) +module/ +browser.js diff --git a/README.md b/README.md index 8f547a14..11a87ed7 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,6 @@ There are over 4000 assertions in the test suite, and tests cover even difficult ## Modularity -Despite the huge improvements in performance and modularity, the new codebase is smaller than v0.2.x, currently clocking at 7.43 KB min+gzip +Despite the huge improvements in performance and modularity, the new codebase is smaller than v0.2.x, currently clocking at 7.47 KB min+gzip In addition, Mithril is now completely modular: you can import only the modules that you need and easily integrate 3rd party modules if you wish to use a different library for routing, ajax, and even rendering diff --git a/api/router.js b/api/router.js index 95ef6afd..1e1650d5 100644 --- a/api/router.js +++ b/api/router.js @@ -1,47 +1,59 @@ "use strict" var Vnode = require("../render/vnode") +var Promise = require("../promise/promise") var coreRouter = require("../router/router") module.exports = function($window, redrawService) { var routeService = coreRouter($window) - + var identity = function(v) {return v} - var resolver, component, attrs, currentPath, resolve + var render, component, attrs, currentPath, updatePending = false 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") var update = function(routeResolver, comp, params, path) { - resolver = routeResolver, component = comp, attrs = params, currentPath = path, resolve = null - resolver.render = routeResolver.render || identity - render() + component = comp != null && typeof comp.view === "function" ? comp : "div", attrs = params, currentPath = path, updatePending = false + render = (routeResolver.render || identity).bind(routeResolver) + run() } - var render = function() { - if (resolver != null) redrawService.render(root, resolver.render(Vnode(component, attrs.key, attrs))) + var run = function() { + if (render != null) redrawService.render(root, render(Vnode(component, attrs.key, attrs))) + } + var bail = function() { + routeService.setPath(defaultRoute) } routeService.defineRoutes(routes, function(payload, params, path) { if (payload.view) update({}, payload, params, path) else { if (payload.onmatch) { - if (resolve != null) update(payload, component, params, path) - else { - resolve = function(resolved) { - update(payload, resolved, params, path) - } - payload.onmatch(function(resolved) { - if (resolve != null) resolve(resolved) - }, params, path) - } + updatePending = true + Promise.resolve(payload.onmatch(params, path)).then(function(resolved) { + if (updatePending) update(payload, resolved, params, path) + }, bail) } else update(payload, "div", params, path) } - }, function() { - routeService.setPath(defaultRoute) - }) - redrawService.subscribe(root, render) + }, bail) + redrawService.subscribe(root, run) + } + route.set = function(path, data, options) { + if (updatePending) options = {replace: true} + updatePending = false + routeService.setPath(path, data, options) } - route.set = routeService.setPath route.get = function() {return currentPath} - route.prefix = routeService.setPrefix - route.link = routeService.link + route.prefix = function(prefix) {routeService.prefix = prefix} + route.link = function(vnode) { + vnode.dom.setAttribute("href", routeService.prefix + 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(routeService.prefix) === 0) href = href.slice(routeService.prefix.length) + route.set(href, undefined, undefined) + } + } + return route } diff --git a/api/tests/test-router.js b/api/tests/test-router.js index 09c46eee..7ea00195 100644 --- a/api/tests/test-router.js +++ b/api/tests/test-router.js @@ -5,9 +5,11 @@ var callAsync = require("../../test-utils/callAsync") var browserMock = require("../../test-utils/browserMock") var m = require("../../render/hyperscript") +var callAsync = require("../../test-utils/callAsync") var coreRenderer = require("../../render/render") var apiRedraw = require("../../api/redraw") var apiRouter = require("../../api/router") +var Promise = require("../../promise/promise") o.spec("route", function() { void [{protocol: "http:", hostname: "localhost"}, {protocol: "file:", hostname: "/"}].forEach(function(env) { @@ -73,7 +75,7 @@ o.spec("route", function() { } }) - setTimeout(function() { + callAsync(function() { o(root.firstChild.nodeName).equals("DIV") $window.history.back() @@ -81,7 +83,7 @@ o.spec("route", function() { o($window.location.pathname).equals("/") done() - }, FRAME_BUDGET) + }) }) o("default route does not inherit params", function(done) { @@ -156,11 +158,11 @@ o.spec("route", function() { o(onclick.args[0].target).equals(root.firstChild) // Wrapped to give time for the rate-limited redraw to fire - setTimeout(function() { + callAsync(function() { o(onupdate.callCount).equals(1) done() - }, FRAME_BUDGET * 2) + }) }) o("event handlers can skip redraw", function(done) { @@ -191,11 +193,11 @@ o.spec("route", function() { o(oninit.callCount).equals(1) // Wrapped to ensure no redraw fired - setTimeout(function() { + callAsync(function() { o(onupdate.callCount).equals(0) done() - }, FRAME_BUDGET) + }) }) o("changes location on route.link", function() { @@ -229,23 +231,23 @@ o.spec("route", function() { o($window.location.href).equals(env.protocol + "//" + (env.hostname === "/" ? "" : env.hostname) + slash + (prefix ? prefix + "/" : "") + "test") }) - o("accepts RouteResolver", function() { + o("accepts RouteResolver with onmatch that returns Component", function(done) { var matchCount = 0 var renderCount = 0 var Component = { view: function() { - return m("div") + return m("span") } } var resolver = { - onmatch: function(resolve, args, requestedPath) { + onmatch: function(args, requestedPath) { matchCount++ o(args.id).equals("abc") o(requestedPath).equals("/abc") o(this).equals(resolver) - resolve(Component) + return Component }, render: function(vnode) { renderCount++ @@ -262,12 +264,175 @@ o.spec("route", function() { "/:id" : resolver }) - o(matchCount).equals(1) - o(renderCount).equals(1) - o(root.firstChild.nodeName).equals("DIV") + callAsync(function() { + o(matchCount).equals(1) + o(renderCount).equals(1) + o(root.firstChild.nodeName).equals("SPAN") + done() + }) }) - o("accepts RouteResolver without `render` method as payload", function() { + o("accepts RouteResolver with onmatch that returns Promise", function(done) { + var matchCount = 0 + var renderCount = 0 + var Component = { + view: function() { + return m("span") + } + } + + var resolver = { + onmatch: function(args, requestedPath) { + matchCount++ + + o(args.id).equals("abc") + o(requestedPath).equals("/abc") + o(this).equals(resolver) + return Promise.resolve(Component) + }, + render: function(vnode) { + renderCount++ + + o(vnode.attrs.id).equals("abc") + o(this).equals(resolver) + + return vnode + }, + } + + $window.location.href = prefix + "/abc" + route(root, "/abc", { + "/:id" : resolver + }) + + callAsync(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) { + var matchCount = 0 + var renderCount = 0 + var Component = { + view: function() { + return m("span") + } + } + + var resolver = { + onmatch: function(args, requestedPath) { + matchCount++ + + o(args.id).equals("abc") + o(requestedPath).equals("/abc") + o(this).equals(resolver) + return Promise.resolve() + }, + render: function(vnode) { + renderCount++ + + o(vnode.attrs.id).equals("abc") + o(this).equals(resolver) + + return vnode + }, + } + + $window.location.href = prefix + "/abc" + route(root, "/abc", { + "/:id" : resolver + }) + + callAsync(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) { + var matchCount = 0 + var renderCount = 0 + var Component = { + view: function() { + return m("span") + } + } + + var resolver = { + onmatch: function(args, requestedPath) { + matchCount++ + + o(args.id).equals("abc") + o(requestedPath).equals("/abc") + o(this).equals(resolver) + return Promise.resolve([]) + }, + render: function(vnode) { + renderCount++ + + o(vnode.attrs.id).equals("abc") + o(this).equals(resolver) + + return vnode + }, + } + + $window.location.href = prefix + "/abc" + route(root, "/abc", { + "/:id" : resolver + }) + + callAsync(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) { + var matchCount = 0 + var renderCount = 0 + var spy = o.spy() + var Component = { + view: function() { + return m("span") + } + } + + var resolver = { + onmatch: function(args, requestedPath) { + matchCount++ + return Promise.reject(new Error("error")) + }, + render: function(vnode) { + renderCount++ + return vnode + }, + } + + $window.location.href = prefix + "/test/1" + route(root, "/default", { + "/default" : {view: spy}, + "/test/:id" : resolver + }) + + callAsync(function() { + callAsync(function() { + o(matchCount).equals(1) + o(renderCount).equals(0) + o(spy.callCount).equals(1) + done() + }) + }) + }) + + o("accepts RouteResolver without `render` method as payload", function(done) { var matchCount = 0 var Component = { view: function() { @@ -278,29 +443,29 @@ o.spec("route", function() { $window.location.href = prefix + "/abc" route(root, "/abc", { "/:id" : { - onmatch: function(resolve, args, requestedPath) { + onmatch: function(args, requestedPath) { matchCount++ o(args.id).equals("abc") o(requestedPath).equals("/abc") - resolve(Component) + return Component }, }, }) - o(matchCount).equals(1) - - o(root.firstChild.nodeName).equals("DIV") + callAsync(function() { + o(matchCount).equals(1) + o(root.firstChild.nodeName).equals("DIV") + done() + }) }) o("changing `vnode.key` in `render` resets the component", function(done, timeout){ - timeout(FRAME_BUDGET * 6) - var oninit = o.spy() var Component = { oninit: oninit, - view: function(){ + view: function() { return m("div") } } @@ -310,14 +475,14 @@ o.spec("route", function() { return m(Component, {key: vnode.attrs.id}) }} }) - setTimeout(function(){ + callAsync(function() { o(oninit.callCount).equals(1) - route.set('/def') - setTimeout(function(){ + route.set("/def") + callAsync(function() { o(oninit.callCount).equals(2) done() - }, FRAME_BUDGET) - }, FRAME_BUDGET) + }) + }) }) o("accepts RouteResolver without `onmatch` method as payload", function() { @@ -371,14 +536,14 @@ o.spec("route", function() { route.set("/b") - setTimeout(function() { + callAsync(function() { o(root.firstChild).equals(dom) done() - }, FRAME_BUDGET) + }) }) - o("calls onmatch and view correct number of times", function() { + o("calls onmatch and view correct number of times", function(done) { var matchCount = 0 var renderCount = 0 var Component = { @@ -390,9 +555,9 @@ o.spec("route", function() { $window.location.href = prefix + "/" route(root, "/", { "/" : { - onmatch: function(resolve) { + onmatch: function() { matchCount++ - resolve(Component) + return Component }, render: function(vnode) { renderCount++ @@ -401,48 +566,126 @@ o.spec("route", function() { }, }) - o(matchCount).equals(1) - o(renderCount).equals(1) + callAsync(function() { + o(matchCount).equals(1) + o(renderCount).equals(1) - redrawService.redraw() + redrawService.redraw() - o(matchCount).equals(1) - o(renderCount).equals(2) + 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) { + var matchCount = 0 + var renderCount = 0 + var Component = { + view: function() { + return m("div") + } + } + + $window.location.href = prefix + "/" + route(root, "/", { + "/" : { + onmatch: function() { + matchCount++ + }, + render: function(vnode) { + renderCount++ + return {tag: Component} + }, + }, + }) + + callAsync(function() { + o(matchCount).equals(1) + o(renderCount).equals(1) + + redrawService.redraw() + + o(matchCount).equals(1) + o(renderCount).equals(2) + + done() + }) }) o("onmatch can redirect to another route", 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" : { - view: function(vnode){ + view: function() { redirected = true } } }) - setTimeout(function() { + callAsync(function() { + o(render.callCount).equals(0) o(redirected).equals(true) done() - }, FRAME_BUDGET) + }) }) - o("onmatch can redirect to another route that has RouteResolver", function(done) { + o("onmatch can redirect to another route that has RouteResolver w/ only onmatch", function(done) { var redirected = false + var render = o.spy() + var view = o.spy(function() {return m("div")}) $window.location.href = prefix + "/a" route(root, "/a", { "/a" : { onmatch: function() { route.set("/b") + }, + render: render + }, + "/b" : { + onmatch: function() { + redirected = true + return {view: 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") + + 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(vnode){ @@ -451,45 +694,219 @@ o.spec("route", function() { } }) - setTimeout(function() { + callAsync(function() { + o(render.callCount).equals(0) o(redirected).equals(true) done() - }, FRAME_BUDGET) + }) }) - o("onmatch resolution callback resolves at most once", function(done) { - var resolveCount = 0 - var resolvedComponent - var A = {view: function() {}} - var B = {view: function() {}} - var C = {view: function() {}} + o("onmatch can redirect to another route that has RouteResolver whose onmatch resolves asynchronously", function(done) { + var redirected = false + var render = o.spy() + var view = o.spy() - $window.location.href = prefix + "/" - route(root, "/", { - "/": { - onmatch: function(resolve) { - resolve(A) - resolve(B) - callAsync(function() {resolve(C)}) + $window.location.href = prefix + "/a" + route(root, "/a", { + "/a" : { + onmatch: function() { + route.set("/b") + }, + render: render + }, + "/b" : { + onmatch: 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() + }) + }) + }) + }) + + o("onmatch can redirect to another route asynchronously", function(done) { + var redirected = false + var render = o.spy() + var view = o.spy() + + $window.location.href = prefix + "/a" + route(root, "/a", { + "/a" : { + onmatch: function() { + callAsync(function() {route.set("/b")}) + return new Promise(function() {}) + }, + render: render + }, + "/b" : { + onmatch: function() { + redirected = true + return {view: view} + } + } + }) + + callAsync(function() { + callAsync(function() { + callAsync(function() { + o(render.callCount).equals(0) + o(redirected).equals(true) + o(view.callCount).equals(1) + + done() + }) + }) + }) + }) + + o("onmatch can redirect w/ window.history.back()", function(done) { + + var render = o.spy() + var component = {view: o.spy()} + + $window.location.href = prefix + "/a" + route(root, "/a", { + "/a" : { + onmatch: function() { + return component }, render: function(vnode) { - resolveCount++ - resolvedComponent = vnode.tag + return vnode } }, + "/b" : { + onmatch: function() { + $window.history.back() + return new Promise(function() {}) + }, + render: render + } }) - setTimeout(function() { - o(resolveCount).equals(1) - o(resolvedComponent).equals(A) - done() - }, FRAME_BUDGET) + callAsync(function() { + route.set('/b') + callAsync(function() { + callAsync(function() { + callAsync(function() { + o(render.callCount).equals(0) + o(component.view.callCount).equals(2) + + done() + }) + }) + }) + }) + }) + + o("onmatch can redirect to a non-existent route that defaults to a RouteResolver w/ onmatch", 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" : { + onmatch: function(vnode){ + redirected = true + return {view: function() {}} + } + } + }) + + callAsync(function() { + callAsync(function() { + o(render.callCount).equals(0) + o(redirected).equals(true) + + done() + }) + }) + }) + + o("onmatch can redirect to a non-existent route that defaults to a RouteResolver w/ render", 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" : { + render: function(vnode){ + redirected = true + } + } + }) + + callAsync(function() { + callAsync(function() { + o(render.callCount).equals(0) + o(redirected).equals(true) + + done() + }) + }) + }) + + 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(vnode){ + 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) { var view = o.spy() - var onmatch = o.spy() + var onmatch = o.spy(function() { + return new Promise(function() {}) + }) $window.location.href = prefix + "/a" route(root, "/", { @@ -502,7 +919,7 @@ o.spec("route", function() { route.set("/b") - setTimeout(function(){ + callAsync(function() { o(view.callCount).equals(1) o(onmatch.callCount).equals(1) @@ -512,12 +929,12 @@ o.spec("route", function() { 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()}) - var render = o.spy(function(){return m("div")}) + var onmatch = o.spy() + var render = o.spy(function() {return m("div")}) $window.location.href = prefix + "/" route(root, '/', { @@ -527,25 +944,33 @@ o.spec("route", function() { } }) - o(onmatch.callCount).equals(1) - o(render.callCount).equals(1) + callAsync(function() { + o(onmatch.callCount).equals(1) + o(render.callCount).equals(1) - route.set(route.get()) + route.set(route.get()) - setTimeout(function() { - o(onmatch.callCount).equals(2) - o(render.callCount).equals(2) + callAsync(function() { + callAsync(function() { + o(onmatch.callCount).equals(2) + o(render.callCount).equals(2) - done() - }, FRAME_BUDGET) + done() + }) + }) + }) }) o("m.route.get() returns the last fully resolved route (#1276)", function(done){ $window.location.href = prefix + "/" route(root, "/", { - "/": {view: function(){}}, - "/2": {onmatch: function(){}} + "/": {view: function() {}}, + "/2": { + onmatch: function() { + return new Promise(function() {}) + } + } }) @@ -553,15 +978,13 @@ o.spec("route", function() { route.set("/2") - setTimeout(function(){ + callAsync(function() { o(route.get()).equals("/") done() - }, FRAME_BUDGET) + }) }) - o("routing with RouteResolver works more than once", function(done, timeout) { - timeout(200) - + o("routing with RouteResolver works more than once", function(done) { $window.location.href = prefix + "/a" route(root, '/a', { '/a': { @@ -578,44 +1001,96 @@ o.spec("route", function() { route.set('/b') - setTimeout(function(){ + callAsync(function() { route.set('/a') - setTimeout(function(){ + callAsync(function() { o(root.firstChild.nodeName).equals("A") done() - }, FRAME_BUDGET) - }, FRAME_BUDGET) + }) + }) }) - o("calling route.set invalidates pending onmatch resolution", function(done, timeout) { - timeout(200) - + o("calling route.set invalidates pending onmatch resolution", function(done) { + var rendered = false var resolved $window.location.href = prefix + "/a" route(root, "/a", { "/a": { - onmatch: function(resolve) { - setTimeout(resolve, 20) + onmatch: function() { + return new Promise(function(resolve) { + callAsync(function() { + callAsync(function() { + resolve({view: function() {}}) + }) + }) + }) }, - render: function(vnode) {resolved = "a"} + render: function(vnode) { + rendered = true + resolved = "a" + } }, "/b": { - view: function() {resolved = "b"} + view: function() { + resolved = "b" + } } }) route.set("/b") - setTimeout(function() { + callAsync(function() { + o(rendered).equals(false) o(resolved).equals("b") done() - }, 30) + }) }) - o("route changes activate onbeforeremove", function(done, timeout) { + o("calling route.set invalidates pending onmatch resolution", function(done) { + 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}}) + }) + }) + }) + }, + render: function(vnode) { + rendered = true + resolved = "a" + } + }, + "/b": { + view: function() { + resolved = "b" + } + } + }) + + route.set("/b") + + callAsync(function() { + o(rendered).equals(false) + o(resolved).equals("b") + + callAsync(function() { + o(rendered).equals(false) + o(resolved).equals("b") + done() + }) + }) + }) + + o("route changes activate onbeforeremove", function(done) { var spy = o.spy() $window.location.href = prefix + "/a" @@ -631,13 +1106,46 @@ o.spec("route", function() { route.set("/b") - setTimeout(function() { + callAsync(function() { o(spy.callCount).equals(1) done() - }, 30) + }) }) + o("asynchronous route.set in onmatch works", function(done) { + var rendered = false, resolved + route(root, "/a", { + "/a": { + onmatch: function() { + return Promise.resolve().then(function() { + route.set("/b") + }) + }, + render: function(vnode) { + rendered = true + resolved = "a" + } + }, + "/b": { + view: 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() + }) + }) + }) + }) + o("throttles", function(done, timeout) { timeout(200) @@ -654,12 +1162,12 @@ o.spec("route", function() { redrawService.redraw() var after = i - setTimeout(function(){ + setTimeout(function() { o(before).equals(1) // routes synchronously o(after).equals(2) // redraws synchronously o(i).equals(3) // throttles rest done() - },40) + }, FRAME_BUDGET * 2) }) }) }) diff --git a/docs/api.md b/docs/api.md index eb2a7b31..cf25070e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -126,7 +126,7 @@ var querystring = m.buildQueryString({a: "1", b: "2"}) ```javascript var state = { value: "", - setValue: function(v) {value = v} + setValue: function(v) {state.value = v} } var Component = { diff --git a/docs/change-log.md b/docs/change-log.md index 2698ad9d..0475c996 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -13,7 +13,9 @@ If you are migrating, consider using the [mithril-codemods](https://www.npmjs.co - [`m.prop` removed](#mprop-removed) - [`m.component` removed](#mcomponent-removed) - [`config` function](#config-function) -- [Cancelling redraw from event handlers](#cancelling-redraw-from-event-handlers) +- [Changes in redraw behaviour](#changes-in-redraw-behaviour) + - [No more redraw locks](#no-more-redraw-locks) + - [Cancelling redraw from event handlers](#cancelling-redraw-from-event-handlers) - [Component `controller` function](#component-controller-function) - [Component arguments](#component-arguments) - [`view()` parameters](#view-parameters) @@ -25,6 +27,7 @@ If you are migrating, consider using the [mithril-codemods](https://www.npmjs.co - [Accessing route params](#accessing-route-params) - [Preventing unmounting](#preventing-unmounting) - [`m.request`](#mrequest) +- [`m.deferred` removed](#mdeferred-removed) - [`m.sync` removed](#msync-removed) - [`xlink` namespace required](#xlink-namespace-required) - [Nested arrays in views](#nested-arrays-in-views) @@ -118,7 +121,15 @@ If available the DOM-Element of the vnode can be accessed at `vnode.dom`. --- -## Cancelling redraw from event handlers +## Changes in redraw behaviour + +Mithril's rendering engine still operates on the basis of semi-automated global redraws, but some APIs and behaviours differ: + +### No more redraw locks + +In v0.2.x, Mithril allowed 'redraw locks' which temporarily prevented blocked draw logic: by default, `m.request` would lock the draw loop on execution and unlock when all pending requests had resolved - the same behaviour could be invoked manually using `m.startComputation()` and `m.endComputation()`. The latter APIs and the associated behaviour has been removed in v1.x. Redraw locking can lead to buggy UIs: the concerns of one part of the application should not be allowed to prevent other parts of the view from updating to reflect change. + +### Cancelling redraw from event handlers `m.mount()` and `m.route()` still automatically redraw after a DOM event handler runs. Cancelling these redraws from within your event handlers is now done by setting the `redraw` property on the passed-in event object to `false`. @@ -492,9 +503,45 @@ Additionally, if the `extract` option is passed to `m.request` the return value --- +## `m.deferred` removed + +`v0.2.x` used its own custom asynchronous contract object, exposed as `m.deferred`, which was used as the basis for `m.request`. `v1.x` uses Promises instead, and implements a [polyfill](promises.md) in non-supporting environments. In situations where you would have used `m.deferred`, you should use Promises instead. + +### `v0.2.x` + +```javascript +var greetAsync = function() { + var deferred = m.deferred(); + setTimeout(function() { + deferred.resolve("hello"); + }, 1000); + return deferred.promise; +}; + +greetAsync() + .then(function(value) {return value + " world"}) + .then(function(value) {console.log(value)}); //logs "hello world" after 1 second +``` + +### `v1.x` + +```javascript +var greetAsync = new Promise(function(resolve){ + setTimeout(function() { + resolve("hello"); + }, 1000); +}); + +greetAsync() + .then(function(value) {return value + " world"}) + .then(function(value) {console.log(value)}); //logs "hello world" after 1 second +``` + +--- + ## `m.sync` removed -`m.sync` has been removed in favor of `Promise.all` +Since `v1.x` uses standards-compliant Promises, `m.sync` is redundant. Use `Promise.all` instead. ### `v0.2.x` @@ -560,7 +607,7 @@ If a vnode is strictly equal to the vnode occupying its place in the last draw, ## `m.startComputation`/`m.endComputation` removed -They are considered anti-patterns and have a number of problematic edge cases, so they no longer exist in v1.x +They are considered anti-patterns and have a number of problematic edge cases, so they no longer exist in v1.x. --- diff --git a/docs/mount.md b/docs/mount.md index a188668b..f8a28e95 100644 --- a/docs/mount.md +++ b/docs/mount.md @@ -4,7 +4,7 @@ - [Signature](#signature) - [How it works](#how-it-works) - [Performance considerations](#performance-considerations) -- [Differences from m.render](#differences-from-m-render) +- [Differences from m.render](#differences-from-mrender) --- @@ -73,4 +73,4 @@ A component rendered via `m.mount` automatically auto-redraws in response to vie `m.mount()` is suitable for application developers integrating Mithril widgets into existing codebases where routing is handled by another library or framework, while still enjoying Mithril's auto-redrawing facilities. -`m.render()` is suitable for library authors who wish to manually control rendering (e.g. when hooking to a third party router, or using third party data-layer libraries like Redux). \ No newline at end of file +`m.render()` is suitable for library authors who wish to manually control rendering (e.g. when hooking to a third party router, or using third party data-layer libraries like Redux). diff --git a/docs/request.md b/docs/request.md index cd204844..75be0de9 100644 --- a/docs/request.md +++ b/docs/request.md @@ -76,7 +76,7 @@ m.request({ }) ``` -Calls to `m.request` return a [promise](promise.md). +A call to `m.request` return a [promise](promise.md) and trigger a redraw upon completion of its promise chain. By default, `m.request` assumes the response is in JSON format and parses it into a Javascript object (or array). diff --git a/docs/route.md b/docs/route.md index b16d1336..2d4112cc 100644 --- a/docs/route.md +++ b/docs/route.md @@ -16,9 +16,10 @@ - [Routing parameters](#routing-parameters) - [Changing router prefix](#changing-router-prefix) - [Advanced component resolution](#advanced-component-resolution) -- [Wrapping a layout component](#wrapping-a-layout-component) -- [Authentication](#authentication) -- [Code splitting](#code-splitting) + - [Wrapping a layout component](#wrapping-a-layout-component) + - [Authentication](#authentication) + - [Preloading data](#preloading-data) + - [Code splitting](#code-splitting) --- @@ -107,18 +108,23 @@ A RouterResolver is an object that contains an `onmatch` method and/or a `render ##### routeResolver.onmatch -The `onmatch` hook is called when the router needs to find a component to render. It is called once when a router path changes, but not on subsequent redraws. It can be used to run logic before a component initializes (for example authentication logic) +The `onmatch` hook is called when the router needs to find a component to render. It is called once per router path changes, but not on subsequent redraws while on the same path. It can be used to run logic before a component initializes (for example authentication logic, data preloading, redirection analytics tracking, etc) -This method also allows you to asynchronously define what component will be rendered, making it suitable for code splitting and asynchronous module loading. +This method also allows you to asynchronously define what component will be rendered, making it suitable for code splitting and asynchronous module loading. To render a component asynchronously return a promise that resolves to a component. -`routeResolver.onmatch(resolve, args, requestedPath)` +For more information on `onmatch`, see the [advanced component resolution](#advanced-component-resolution) section -Argument | Type | Description ---------------- | ------------------------ | --- -`resolve` | `Component -> undefined` | 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.onmatch(args, requestedPath)` + +Argument | Type | Description +--------------- | ------------------------------ | --- +`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** | `Component|Promise` | Returns a component or a promise that resolves to a component + +If `onmatch` returns a component or a promise that resolves to a component, this component is used as the `vnode.tag` for the first argument in the RouteResolver's `render` method. Otherwise, `vnode.tag` is set to `"div"`. Similarly, if the `onmatch` method is omitted, `vnode.tag` is also `"div"`. + +If `onmatch` returns a promise that gets rejected, the router redirects back to `defaultRoute`. You may override this behavior by calling `.catch` on the promise chain before returning it. ##### routeResolver.render @@ -128,8 +134,8 @@ The `render` method is called on every redraw for a matching route. It is simila 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` | `Object` | A [vnode](vnodes.md) whose attributes object contains routing parameters. If onmatch does not return a component or a promise that resolves to a component, the vnode's `tag` field defaults to `"div"` +`vnode.attrs` | `Object` | A map of URL parameter values **returns** | `Vnode` | Returns a vnode --- @@ -258,7 +264,7 @@ m.route(document.body, "/edit/pictures/image.jpg", { --- -### Changing route prefix +### Changing router prefix The router prefix is a fragment of the URL that dictates the underlying [strategy](routing-strategies.md) used by the router. @@ -286,8 +292,8 @@ Instead of mapping a component to a route, you can specify a RouteResolver objec ```javascript m.route(document.body, "/", { "/": { - onmatch: function(resolve, args, requestedPath) { - resolve(Home) + onmatch: function(args, requestedPath) { + return Home }, render: function(vnode) { return vnode // equivalent to m(Home) @@ -300,7 +306,7 @@ RouteResolvers are useful for implementing a variety of advanced routing use cas --- -### Wrapping a layout component +#### Wrapping a layout component It's often desirable to wrap all or most of the routed components in a reusable shell (often called a "layout"). In order to do that, you first need to create a component that contains the common markup that will wrap around the various different components: @@ -344,7 +350,7 @@ Note that in this case, if the Layout component the `oninit` and `oncreate` life --- -### Authentication +#### Authentication The RouterResolver's `onmatch` hook can be used to run logic before the top level component in a route is initializated. The example below shows how to implement a login wall that prevents users from seeing the `/secret` page unless they login. @@ -366,10 +372,12 @@ var Login = { m.route(document.body, "/secret", { "/secret": { - onmatch: function(resolve) { - if (isLoggedIn) resolve(Home) - else m.route.set("/login") + onmatch: function() { + if (!isLoggedIn) m.route.set("/login") }, + render: function() { + return m(Home) + } }, "/login": Login }) @@ -381,11 +389,67 @@ For the sake of simplicity, in the example above, the user's logged in status is --- -### Code splitting +#### Preloading data -In a large application, it may be desirable to download the code for each route on demand, rather than upfront. Dividing the codebase this way is known as code splitting or lazy loading. In Mithril, this can be accomplished by calling the `resolve` callback of the `onmatch` hook asynchronously: +Typically, a component can load data upon initialization. Loading data this way renders the component twice (once upon routing, and once after the request completes). -At its simplest form, one could do the following: +```javascript +var state = { + users: [], + loadUsers: function() { + return m.request("/api/v1/users").then(function(users) { + state.users = users + }) + } +} + +m.route(document.body, "/user/list", { + "/user/list": { + oninit: state.loadUsers, + view: function() { + return state.users.length > 0 ? state.users.map(function() { + return m("div", user.id) + }) : "loading" + } + }, +}) +``` + +In the example above, on the first render, the UI displays `"loading"` since `state.users` is an empty array before the request completes. Then, once data is available, the UI redraws and a list of user ids is shown. + +RouteResolvers can be used as a mechanism to preload data before rendering a component in order to avoid UI flickering and thus bypassing the need for a loading indicator: + +```javascript +var state = { + users: [], + loadUsers: function() { + return m.request("/api/v1/users").then(function(users) { + state.users = users + }) + } +} + +m.route(document.body, "/user/list", { + "/user/list": { + onmatch: state.loadUsers, + render: function() { + return state.users.length > 0 ? state.users.map(function() { + return m("div", user.id) + }) : "loading" + } + }, +}) +``` + +Above, `render` only runs after the request completes, making the ternary operator redundant. + +--- + +#### Code splitting + +In a large application, it may be desirable to download the code for each route on demand, rather than upfront. Dividing the codebase this way is known as code splitting or lazy loading. In Mithril, this can be accomplished by returning a promise from the `onmatch` hook: + +At its most basic form, one could do the following: ```javascript // Home.js @@ -401,21 +465,20 @@ module.export = { ```javascript // index.js -function load(file, done) { - m.request({ +function load(file) { + return m.request({ method: "GET", url: file, extract: function(xhr) { return new Function("var module = {};" + xhr.responseText + ";return module.exports;") } }) - .run(done) } m.route(document.body, "/", { "/": { - onmatch: function(resolve) { - load("Home.js", resolve) + onmatch: function() { + return load("Home.js") }, }, }) @@ -428,9 +491,11 @@ Fortunately, there are a number of tools that facilitate the task of bundling mo ```javascript m.route(document.body, "/", { "/": { - onmatch: function(resolve) { + onmatch: function() { // using Webpack async code splitting - require(['./Home.js'], resolve) + return new Promise(function(resolve) { + require(['./Home.js'], resolve) + }) }, }, }) diff --git a/docs/withAttr.md b/docs/withAttr.md index 10638e51..4a9fd44b 100644 --- a/docs/withAttr.md +++ b/docs/withAttr.md @@ -15,7 +15,7 @@ Returns an event handler that runs `callback` with the value of the specified DO ```javascript var state = { value: "", - setValue: function(v) {value = v} + setValue: function(v) {state.value = v} } var Component = { @@ -63,18 +63,18 @@ document.body.onclick = m.withAttr("title", function(value) { Typically, `m.withAttr()` can be used in Mithril component views to avoid polluting the data layer with DOM event model concerns: ```javascript -var Data = { +var state = { email: "", setEmail: function(email) { - Data.email = email.toLowerCase() + state.email = email.toLowerCase() } } var MyComponent = { view: function() { return m("input", { - oninput: m.withAttr("value", Data.setEmail), - value: Data.email + oninput: m.withAttr("value", state.setEmail), + value: state.email }) } } @@ -89,15 +89,15 @@ m.mount(document.body, MyComponent) The `m.withAttr()` helper reads the value of the element to which the event handler is bound, which is not necessarily the same as the element where the event originated. ```javascript -var Data = { +var state = { url: "", - setURL: function(url) {Data.url = url} + setURL: function(url) {state.url = url} } var MyComponent = { view: function() { - return m("a[href='/foo']", {onclick: m.withAttr("href", Data.setURL)}, [ - m("span", Data.url) + return m("a[href='/foo']", {onclick: m.withAttr("href", state.setURL)}, [ + m("span", state.url) ]) } } @@ -117,21 +117,21 @@ The first argument of `m.withAttr()` can be either an attribute or a property. ```javascript // reads from `select.selectedIndex` property -var Data = { +var state = { index: 0, - setIndex: function(index) {Data.index = index} + setIndex: function(index) {state.index = index} } -m("select", {onclick: m.withAttr("selectedIndex", Data.setIndex)}) +m("select", {onclick: m.withAttr("selectedIndex", state.setIndex)}) ``` If a value can be both an attribute *and* a property, the property value is used. ```javascript // value is a boolean, because the `input.checked` property is boolean -var Data = { +var state = { selected: false, - setSelected: function(selected) {Data.selected = selected} + setSelected: function(selected) {state.selected = selected} } -m("input[type=checkbox]", {onclick: m.withAttr("checked", Data.setSelected)}) +m("input[type=checkbox]", {onclick: m.withAttr("checked", state.setSelected)}) ``` diff --git a/mithril.js b/mithril.js index a1c9f2e9..c9d88a35 100644 --- a/mithril.js +++ b/mithril.js @@ -214,7 +214,7 @@ var _8 = function($window, Promise) { var next = then0.apply(promise0, arguments) next.then(complete, function(e) { complete() - throw e + if (count === 0) throw e }) return finalize(next) } @@ -953,6 +953,7 @@ var _16 = function(redrawService0) { } } m.mount = _16(redrawService) +var Promise = PromisePolyfill var parseQueryString = function(string) { if (string === "" || string == null) return {} if (string.charAt(0) === "?") string = string.slice(1) @@ -986,20 +987,18 @@ var parseQueryString = function(string) { var coreRouter = function($window) { var supportsPushState = typeof $window.history.pushState === "function" var callAsync0 = typeof setImmediate === "function" ? setImmediate : setTimeout - var prefix1 = "#!" - function setPrefix(value) {prefix1 = value} function normalize1(fragment0) { var data = $window.location[fragment0].replace(/(?:%[a-f89][a-f0-9])+/gim, decodeURIComponent) if (fragment0 === "pathname" && data[0] !== "/") data = "/" + data return data } var asyncId - function debounceAsync(f) { + function debounceAsync(callback0) { return function() { if (asyncId != null) return asyncId = callAsync0(function() { asyncId = null - f() + callback0() }) } } @@ -1018,15 +1017,16 @@ var coreRouter = function($window) { } return path.slice(0, pathEnd) } - function getPath() { - var type2 = prefix1.charAt(0) + var router = {prefix: "#!"} + router.getPath = function() { + var type2 = router.prefix.charAt(0) switch (type2) { - case "#": return normalize1("hash").slice(prefix1.length) - case "?": return normalize1("search").slice(prefix1.length) + normalize1("hash") - default: return normalize1("pathname").slice(prefix1.length) + normalize1("search") + normalize1("hash") + case "#": return normalize1("hash").slice(router.prefix.length) + case "?": return normalize1("search").slice(router.prefix.length) + normalize1("hash") + default: return normalize1("pathname").slice(router.prefix.length) + normalize1("search") + normalize1("hash") } } - function setPath(path, data, options) { + router.setPath = function(path, data, options) { var queryData = {}, hashData = {} path = parsePath(path, queryData, hashData) if (data != null) { @@ -1041,15 +1041,15 @@ var coreRouter = function($window) { var hash = buildQueryString(hashData) if (hash) path += "#" + hash if (supportsPushState) { - if (options && options.replace) $window.history.replaceState(null, null, prefix1 + path) - else $window.history.pushState(null, null, prefix1 + path) - $window.onpopstate(true) + if (options && options.replace) $window.history.replaceState(null, null, router.prefix + path) + else $window.history.pushState(null, null, router.prefix + path) + $window.onpopstate() } - else $window.location.href = prefix1 + path + else $window.location.href = router.prefix + path } - function defineRoutes(routes, resolve, reject) { + router.defineRoutes = function(routes, resolve, reject) { function resolveRoute() { - var path = getPath() + var path = router.getPath() var params = {} var pathname = parsePath(path, params, params) @@ -1071,72 +1071,71 @@ var coreRouter = function($window) { } if (supportsPushState) $window.onpopstate = debounceAsync(resolveRoute) - else if (prefix1.charAt(0) === "#") $window.onhashchange = resolveRoute + else if (router.prefix.charAt(0) === "#") $window.onhashchange = resolveRoute resolveRoute() } - function link(vnode2) { - vnode2.dom.setAttribute("href", prefix1 + vnode2.attrs.href) - vnode2.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(prefix1) === 0) href = href.slice(prefix1.length) - setPath(href, undefined, undefined) - } - } - return {setPrefix: setPrefix, getPath: getPath, setPath: setPath, defineRoutes: defineRoutes, link: link} + + return router } var _20 = function($window, redrawService0) { var routeService = coreRouter($window) - var identity = function(v) {return v} - var resolver, component, attrs3, currentPath, resolve + var render1, component, attrs3, currentPath, updatePending = false 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") var update = function(routeResolver, comp, params, path) { - resolver = routeResolver, component = comp, attrs3 = params, currentPath = path, resolve = null - resolver.render = routeResolver.render || identity - render1() + component = comp != null && typeof comp.view === "function" ? comp : "div", attrs3 = params, currentPath = path, updatePending = false + render1 = (routeResolver.render || identity).bind(routeResolver) + run1() } - var render1 = function() { - if (resolver != null) redrawService0.render(root, resolver.render(Vnode(component, attrs3.key, attrs3))) + var run1 = function() { + if (render1 != null) redrawService0.render(root, render1(Vnode(component, attrs3.key, attrs3))) + } + var bail = function() { + routeService.setPath(defaultRoute) } routeService.defineRoutes(routes, function(payload, params, path) { if (payload.view) update({}, payload, params, path) else { if (payload.onmatch) { - if (resolve != null) update(payload, component, params, path) - else { - resolve = function(resolved) { - update(payload, resolved, params, path) - } - payload.onmatch(function(resolved) { - if (resolve != null) resolve(resolved) - }, params, path) - } + updatePending = true + Promise.resolve(payload.onmatch(params, path)).then(function(resolved) { + if (updatePending) update(payload, resolved, params, path) + }, bail) } else update(payload, "div", params, path) } - }, function() { - routeService.setPath(defaultRoute) - }) - redrawService0.subscribe(root, render1) + }, bail) + redrawService0.subscribe(root, run1) + } + route.set = function(path, data, options) { + if (updatePending) options = {replace: true} + updatePending = false + routeService.setPath(path, data, options) } - route.set = routeService.setPath route.get = function() {return currentPath} - route.prefix = routeService.setPrefix - route.link = routeService.link + route.prefix = function(prefix0) {routeService.prefix = prefix0} + route.link = function(vnode1) { + vnode1.dom.setAttribute("href", routeService.prefix + vnode1.attrs.href) + vnode1.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(routeService.prefix) === 0) href = href.slice(routeService.prefix.length) + route.set(href, undefined, undefined) + } + } return route } m.route = _20(window, redrawService) -m.withAttr = function(attrName, callback0, context) { +m.withAttr = function(attrName, callback1, context) { return function(e) { - return callback0.call(context || this, attrName in e.currentTarget ? e.currentTarget[attrName] : e.currentTarget.getAttribute(attrName)) + return callback1.call(context || this, attrName in e.currentTarget ? e.currentTarget[attrName] : e.currentTarget.getAttribute(attrName)) } } -var _27 = coreRenderer(window) -m.render = _27.render +var _28 = coreRenderer(window) +m.render = _28.render m.redraw = redrawService.redraw m.request = requestService.request m.jsonp = requestService.jsonp diff --git a/mithril.min.js b/mithril.min.js index 635f0720..69e76332 100644 --- a/mithril.min.js +++ b/mithril.min.js @@ -1,40 +1,41 @@ -new function(){function r(a,c,h,d,g,l){return{tag:a,key:c,attrs:h,children:d,text:g,dom:l,domSize:void 0,state:{},events:void 0,instance:void 0,skip:!1}}function y(a){if(null==a||"string"!==typeof a&&null==a.view)throw Error("The selector must be either a string or a component.");if("string"===typeof a&&void 0===I[a]){for(var c,h,d=[],g={};c=P.exec(a);){var l=c[1],m=c[2];""===l&&""!==m?h=m:"#"===l?g.id=m:"."===l?d.push(m):"["===c[3][0]&&((l=c[6])&&(l=l.replace(/\\(["'])/g,"$1").replace(/\\\\/g,"\\")), -"class"===c[4]?d.push(l):g[c[4]]=l||!0)}0a.indexOf("?")?"?":"&";a+=d+b}return a}function m(a){try{return""!==a?JSON.parse(a):null}catch(M){throw Error(a);}}function A(a){return a.responseText}function n(a,c){if("function"===typeof a)if(Array.isArray(c))for(var b=0;bk.status||304===k.status)c(n(b.type,a));else{var g=Error(k.responseText),h;for(h in a)g[h]=a[h];d(g)}}catch(F){d(F)}};h&&null!=b.data?k.send(b.data):k.send()});return!0===b.background?u:v(u)},jsonp:function(b,k){var m=h();b=d(b,k);var u=new c(function(c,d){var k=b.callbackName||"_mithril_"+Math.round(1E16*Math.random())+"_"+r++,h=a.document.createElement("script"); -a[k]=function(d){h.parentNode.removeChild(h);c(n(b.type,d));delete a[k]};h.onerror=function(){h.parentNode.removeChild(h);d(Error("JSONP request failed"));delete a[k]};null==b.data&&(b.data={});b.url=g(b.url,b.data);b.data[b.callbackKey||"callback"]=k;h.src=l(b.url,b.data);a.document.documentElement.appendChild(h)});return!0===b.background?u:m(u)},setCompletionCallback:function(a){k=a}}}(window,"undefined"!==typeof Promise?Promise:t),O=function(a){function c(e,f,a,b,c,d,g){for(;a=w&&v>=u;){var x=f[w],p=a[u];if(x!==p||q)if(null==x)w++;else if(null==p)u++;else if(x.key===p.key)w++,u++,l(e,x,p,b,A(f,w,d),q,g),q&&x.tag===p.tag&&n(e,m(x),d);else if(x=f[z],x!==p||q)if(null==x)z--;else if(null==p)u++;else if(x.key===p.key)l(e,x,p,b,A(f,z+1,d),q,g),(q||u=w&&v>=u;){x=f[z];p=a[v];if(x!==p||q)if(null==x)z--;else{if(null!=p)if(x.key===p.key)l(e,x,p,b,A(f,z+1,d),q,g),q&&x.tag===p.tag&&n(e,m(x), -d),null!=x.dom&&(d=x.dom),z--;else{if(!D){D=f;var x=z,r={},C;for(C=0;Ca.indexOf("?")?"?":"&";a+=d+b}return a}function l(a){try{return""!==a?JSON.parse(a):null}catch(D){throw Error(a);}}function n(a){return a.responseText}function p(a, +c){if("function"===typeof a)if(c instanceof Array)for(var b=0;bk.status||304===k.status)c(p(b.type,a));else{var g=Error(k.responseText),h;for(h in a)g[h]=a[h];d(g)}}catch(F){d(F)}};h&&null!=b.data?k.send(b.data):k.send()});return!0===b.background?t:r(t)},jsonp:function(b,l){var n=h();b=d(b,l);var t=new c(function(c, +d){var h=b.callbackName||"_mithril_"+Math.round(1E16*Math.random())+"_"+k++,l=a.document.createElement("script");a[h]=function(d){l.parentNode.removeChild(l);c(p(b.type,d));delete a[h]};l.onerror=function(){l.parentNode.removeChild(l);d(Error("JSONP request failed"));delete a[h]};null==b.data&&(b.data={});b.url=g(b.url,b.data);b.data[b.callbackKey||"callback"]=h;l.src=m(b.url,b.data);a.document.documentElement.appendChild(l)});return!0===b.background?t:n(t)},setCompletionCallback:function(a){r=a}}}(window, +H),O=function(a){function c(e,f,a,b,c,d,g){for(;a=k&&t>=A;){var x=f[k],u=a[A];if(x!==u||q)if(null==x)k++;else if(null==u)A++;else if(x.key===u.key)k++,A++,m(e,x,u,b,n(f,k,d),q,g),q&&x.tag===u.tag&&p(e,l(x),d);else if(x=f[y],x!==u||q)if(null==x)y--;else if(null==u)A++;else if(x.key===u.key)m(e,x,u,b,n(f,y+1,d),q,g),(q||A=k&&t>=A;){x=f[y];u=a[t];if(x!==u||q)if(null==x)y--;else{if(null!= +u)if(x.key===u.key)m(e,x,u,b,n(f,y+1,d),q,g),q&&x.tag===u.tag&&p(e,l(x),d),null!=x.dom&&(d=x.dom),y--;else{if(!D){D=f;var x=y,C={},w;for(w=0;w + diff --git a/request/tests/test-request.js b/request/tests/test-request.js index 1de21861..86643e95 100644 --- a/request/tests/test-request.js +++ b/request/tests/test-request.js @@ -1,6 +1,7 @@ "use strict" var o = require("../../ospec/ospec") +var callAsync = require("../../test-utils/callAsync") var xhrMock = require("../../test-utils/xhrMock") var Request = require("../../request/request") var Promise = require("../../promise/promise") @@ -336,7 +337,7 @@ o.spec("xhr", function() { } }) var promise = xhr("/item", {background: true}).then(function() {}) - + setTimeout(function() { o(complete.callCount).equals(0) done() @@ -377,5 +378,31 @@ o.spec("xhr", function() { o(e.message).equals("error") }).then(done) }) + o("triggers all branched catches upon rejection", function(done) { + mock.$defineRoutes({ + "GET /item": function(request) { + return {status: 500, responseText: "error"} + } + }) + var request = xhr({method: "GET", url: "/item"}) + var then = o.spy() + var catch1 = o.spy() + var catch2 = o.spy() + var catch3 = o.spy() + + request.catch(catch1) + request.then(then, catch2) + request.then(then).catch(catch3) + + callAsync(function() { + callAsync(function() { + o(catch1.callCount).equals(1) + o(then.callCount).equals(0) + o(catch2.callCount).equals(1) + o(catch3.callCount).equals(1) + done() + }) + }) + }) }) }) diff --git a/router/router.js b/router/router.js index 6a3ac597..6e1d7270 100644 --- a/router/router.js +++ b/router/router.js @@ -7,9 +7,6 @@ module.exports = function($window) { var supportsPushState = typeof $window.history.pushState === "function" var callAsync = typeof setImmediate === "function" ? setImmediate : setTimeout - var prefix = "#!" - function setPrefix(value) {prefix = value} - function normalize(fragment) { var data = $window.location[fragment].replace(/(?:%[a-f89][a-f0-9])+/gim, decodeURIComponent) if (fragment === "pathname" && data[0] !== "/") data = "/" + data @@ -17,14 +14,13 @@ module.exports = function($window) { } var asyncId - function debounceAsync(f) { + function debounceAsync(callback) { return function() { if (asyncId != null) return asyncId = callAsync(function() { asyncId = null - f() + callback() }) - } } @@ -44,16 +40,16 @@ module.exports = function($window) { return path.slice(0, pathEnd) } - function getPath() { - var type = prefix.charAt(0) + var router = {prefix: "#!"} + router.getPath = function() { + var type = router.prefix.charAt(0) switch (type) { - case "#": return normalize("hash").slice(prefix.length) - case "?": return normalize("search").slice(prefix.length) + normalize("hash") - default: return normalize("pathname").slice(prefix.length) + normalize("search") + normalize("hash") + case "#": return normalize("hash").slice(router.prefix.length) + case "?": return normalize("search").slice(router.prefix.length) + normalize("hash") + default: return normalize("pathname").slice(router.prefix.length) + normalize("search") + normalize("hash") } } - - function setPath(path, data, options) { + router.setPath = function(path, data, options) { var queryData = {}, hashData = {} path = parsePath(path, queryData, hashData) if (data != null) { @@ -71,16 +67,15 @@ module.exports = function($window) { if (hash) path += "#" + hash if (supportsPushState) { - if (options && options.replace) $window.history.replaceState(null, null, prefix + path) - else $window.history.pushState(null, null, prefix + path) - $window.onpopstate(true) + if (options && options.replace) $window.history.replaceState(null, null, router.prefix + path) + else $window.history.pushState(null, null, router.prefix + path) + $window.onpopstate() } - else $window.location.href = prefix + path + else $window.location.href = router.prefix + path } - - function defineRoutes(routes, resolve, reject) { + router.defineRoutes = function(routes, resolve, reject) { function resolveRoute() { - var path = getPath() + var path = router.getPath() var params = {} var pathname = parsePath(path, params, params) @@ -104,21 +99,9 @@ module.exports = function($window) { } if (supportsPushState) $window.onpopstate = debounceAsync(resolveRoute) - else if (prefix.charAt(0) === "#") $window.onhashchange = resolveRoute + else if (router.prefix.charAt(0) === "#") $window.onhashchange = resolveRoute resolveRoute() } - - function link(vnode) { - vnode.dom.setAttribute("href", prefix + 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(prefix) === 0) href = href.slice(prefix.length) - setPath(href, undefined, undefined) - } - } - - return {setPrefix: setPrefix, getPath: getPath, setPath: setPath, defineRoutes: defineRoutes, link: link} + + return router } diff --git a/router/tests/index.html b/router/tests/index.html index f0feab4f..1f186449 100644 --- a/router/tests/index.html +++ b/router/tests/index.html @@ -11,6 +11,7 @@ + @@ -19,7 +20,6 @@ - diff --git a/router/tests/test-defineRoutes.js b/router/tests/test-defineRoutes.js index b00530b7..51b3cc09 100644 --- a/router/tests/test-defineRoutes.js +++ b/router/tests/test-defineRoutes.js @@ -14,7 +14,7 @@ o.spec("Router.defineRoutes", function() { o.beforeEach(function() { $window = pushStateMock(env) router = new Router($window) - router.setPrefix(prefix) + router.prefix = prefix onRouteChange = o.spy() onFail = o.spy() }) @@ -73,7 +73,7 @@ o.spec("Router.defineRoutes", function() { $window.location.href = "file://" + prefix + "/test" router = new Router($window) - router.setPrefix(prefix) + router.prefix = prefix router.defineRoutes({"/test": {data: 1}}, onRouteChange, onFail) diff --git a/router/tests/test-getPath.js b/router/tests/test-getPath.js index 119c1713..6c5dc2b2 100644 --- a/router/tests/test-getPath.js +++ b/router/tests/test-getPath.js @@ -13,7 +13,7 @@ o.spec("Router.getPath", function() { o.beforeEach(function() { $window = pushStateMock(env) router = new Router($window) - router.setPrefix(prefix) + router.prefix = prefix onRouteChange = o.spy() onFail = o.spy() }) diff --git a/router/tests/test-link.js b/router/tests/test-link.js deleted file mode 100644 index 3d07af2e..00000000 --- a/router/tests/test-link.js +++ /dev/null @@ -1,87 +0,0 @@ -"use strict" - -var o = require("../../ospec/ospec") -var renderService = require("../../render/render") -var callAsync = require("../../test-utils/callAsync") -var pushStateMock = require("../../test-utils/pushStateMock") -var domMock = require("../../test-utils/domMock") -var Router = require("../../router/router") - -o.spec("Router.link", 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 $window, dom, root, router, onRouteChange, onFail, render - - o.beforeEach(function() { - $window = pushStateMock(env) - dom = domMock() - root = dom.document.body - router = new Router($window) - router.setPrefix(prefix) - onRouteChange = o.spy() - onFail = o.spy() - render = renderService(dom).render - }) - - o("works", function(done) { - var A = { - view: function() { - return {tag: "a", attrs: {href: "/b", oncreate: router.link}} - } - } - var B = { - view: function() { - return {tag: "a", attrs: {href: "/a", oncreate: router.link}} - } - } - - $window.location.href = prefix + "/a" - router.defineRoutes({"/a": {tag: A}, "/b": {tag: B}}, function(component) { - render(root, component) - }) - - callAsync(function() { - var e = dom.document.createEvent("MouseEvents") - e.initEvent("click", true, true) - root.firstChild.dispatchEvent(e) - - callAsync(function() { - o(router.getPath()).equals("/b") - - done() - }) - }) - }) - - o("works after update", function(done) { - var id = "a" - var A = { - view: function() { - return {tag: "a", attrs: {href: "/" + id, oncreate: router.link}} - } - } - - $window.location.href = prefix + "/a" - router.defineRoutes({"/a": {tag: A}, "/b": {tag: A}}, function(component) { - render(root, {tag: A}) - id = "b" - render(root, {tag: A}) - }) - - callAsync(function() { - var e = dom.document.createEvent("MouseEvents") - e.initEvent("click", true, true) - root.firstChild.dispatchEvent(e) - - callAsync(function() { - o(router.getPath()).equals("/b") - - done() - }) - }) - }) - }) - }) - }) -}) diff --git a/router/tests/test-setPath.js b/router/tests/test-setPath.js index 6b817cfb..c957a59a 100644 --- a/router/tests/test-setPath.js +++ b/router/tests/test-setPath.js @@ -14,7 +14,7 @@ o.spec("Router.setPath", function() { o.beforeEach(function() { $window = pushStateMock(env) router = new Router($window) - router.setPrefix(prefix) + router.prefix = prefix onRouteChange = o.spy() onFail = o.spy() }) @@ -88,7 +88,7 @@ o.spec("Router.setPath", function() { $window.location.href = "file://" + prefix + "/test" router = new Router($window) - router.setPrefix(prefix) + router.prefix = prefix router.defineRoutes({"/test": {data: 1}, "/other/:a/:b...": {data: 2}}, onRouteChange, onFail) diff --git a/test-utils/browserMock.js b/test-utils/browserMock.js index d667772a..ead6e9e4 100644 --- a/test-utils/browserMock.js +++ b/test-utils/browserMock.js @@ -5,14 +5,14 @@ var domMock = require("./domMock") var xhrMock = require("./xhrMock") module.exports = function(env) { - var $window = {} + env = env || {} + var $window = env.window = {} var dom = domMock() var xhr = xhrMock() - var ps = pushStateMock(env) for (var key in dom) if (!$window[key]) $window[key] = dom[key] for (var key in xhr) if (!$window[key]) $window[key] = xhr[key] - for (var key in ps) if (!$window[key]) $window[key] = ps[key] + pushStateMock(env) return $window } \ No newline at end of file diff --git a/test-utils/pushStateMock.js b/test-utils/pushStateMock.js index 3e4ed1f7..d23b3a1f 100644 --- a/test-utils/pushStateMock.js +++ b/test-utils/pushStateMock.js @@ -5,6 +5,7 @@ var parseURL = require("../test-utils/parseURL") module.exports = function(options) { if (options == null) options = {} + var $window = options.window || {} var protocol = options.protocol || "http:" var hostname = options.hostname || "localhost" var port = "" @@ -32,7 +33,7 @@ module.exports = function(options) { } return isNew } - + function prefix(prefix, value) { if (value === "") return "" return (value.charAt(0) !== prefix ? prefix : "") + value @@ -46,125 +47,125 @@ module.exports = function(options) { function unload() { if (typeof $window.onunload === "function") $window.onunload({type: "unload"}) } - var $window = { - location: { - get protocol() { - return protocol - }, - get hostname() { - return hostname - }, - get port() { - return port - }, - get pathname() { - return pathname - }, - get search() { - return search - }, - get hash() { - return hash - }, - get origin() { - if (protocol === "file:") return "null" - return protocol + "//" + hostname + prefix(":", port) - }, - get host() { - if (protocol === "file:") return "" - return hostname + prefix(":", port) - }, - get href() { - return getURL() - }, - set protocol(value) { - throw new Error("Protocol is read-only") - }, - set hostname(value) { - unload() - past.push({url: getURL(), isNew: true}) - future = [] - hostname = value - }, - set port(value) { - if (protocol === "file:") throw new Error("Port is read-only under `file://` protocol") - unload() - past.push({url: getURL(), isNew: true}) - future = [] - port = value - }, - set pathname(value) { - if (protocol === "file:") throw new Error("Pathname is read-only under `file://` protocol") - unload() - past.push({url: getURL(), isNew: true}) - future = [] - pathname = prefix("/", value) - }, - set search(value) { - unload() - past.push({url: getURL(), isNew: true}) - future = [] - search = prefix("?", value) - }, - set hash(value) { - var oldHash = hash - past.push({url: getURL(), isNew: false}) - future = [] - hash = prefix("#", value) - if (oldHash != hash) hashchange() - }, + $window.location = { + get protocol() { + return protocol + }, + get hostname() { + return hostname + }, + get port() { + return port + }, + get pathname() { + return pathname + }, + get search() { + return search + }, + get hash() { + return hash + }, + get origin() { + if (protocol === "file:") return "null" + return protocol + "//" + hostname + prefix(":", port) + }, + get host() { + if (protocol === "file:") return "" + return hostname + prefix(":", port) + }, + get href() { + return getURL() + }, - set origin(value) { - //origin is writable but ignored - }, - set host(value) { - //host is writable but ignored in Chrome - }, - set href(value) { - var url = getURL() - var isNew = setURL(value) - if (isNew) { - setURL(url) - unload() - setURL(value) - } - past.push({url: url, isNew: isNew}) - future = [] - }, + set protocol(value) { + throw new Error("Protocol is read-only") }, - history: { - pushState: function(data, title, url) { - past.push({url: getURL(), isNew: false}) - future = [] - setURL(url) - }, - replaceState: function(data, title, url) { - future = [] - setURL(url) - }, - back: function() { - var entry = past.pop() - if (entry != null) { - if (entry.isNew) unload() - future.push({url: getURL(), isNew: false}) - setURL(entry.url) - if (!entry.isNew) popstate() - } - }, - forward: function() { - var entry = future.pop() - if (entry != null) { - if (entry.isNew) unload() - past.push({url: getURL(), isNew: false}) - setURL(entry.url) - if (!entry.isNew) popstate() - } - }, + set hostname(value) { + unload() + past.push({url: getURL(), isNew: true}) + future = [] + hostname = value + }, + set port(value) { + if (protocol === "file:") throw new Error("Port is read-only under `file://` protocol") + unload() + past.push({url: getURL(), isNew: true}) + future = [] + port = value + }, + set pathname(value) { + if (protocol === "file:") throw new Error("Pathname is read-only under `file://` protocol") + unload() + past.push({url: getURL(), isNew: true}) + future = [] + pathname = prefix("/", value) + }, + set search(value) { + unload() + past.push({url: getURL(), isNew: true}) + future = [] + search = prefix("?", value) + }, + set hash(value) { + var oldHash = hash + past.push({url: getURL(), isNew: false}) + future = [] + hash = prefix("#", value) + if (oldHash != hash) hashchange() + }, + + set origin(value) { + //origin is writable but ignored + }, + set host(value) { + //host is writable but ignored in Chrome + }, + set href(value) { + var url = getURL() + var isNew = setURL(value) + if (isNew) { + setURL(url) + unload() + setURL(value) + } + past.push({url: url, isNew: isNew}) + future = [] }, - onpopstate: null, - onhashchange: null, - onunload: null, } + $window.history = { + pushState: function(data, title, url) { + past.push({url: getURL(), isNew: false}) + future = [] + setURL(url) + }, + replaceState: function(data, title, url) { + future = [] + setURL(url) + }, + back: function() { + var entry = past.pop() + if (entry != null) { + if (entry.isNew) unload() + future.push({url: getURL(), isNew: false}) + setURL(entry.url) + if (!entry.isNew) popstate() + } + }, + forward: function() { + var entry = future.pop() + if (entry != null) { + if (entry.isNew) unload() + past.push({url: getURL(), isNew: false}) + setURL(entry.url) + if (!entry.isNew) popstate() + } + }, + } + $window.onpopstate = null, + $window.onhashchange = null, + $window.onunload = null + return $window } diff --git a/test-utils/tests/index.html b/test-utils/tests/index.html index 4e5a6234..e24fa2f8 100644 --- a/test-utils/tests/index.html +++ b/test-utils/tests/index.html @@ -13,11 +13,13 @@ + + diff --git a/test-utils/tests/test-browserMock.js b/test-utils/tests/test-browserMock.js new file mode 100644 index 00000000..d47c58ce --- /dev/null +++ b/test-utils/tests/test-browserMock.js @@ -0,0 +1,40 @@ +"use strict" + +var o = require("../../ospec/ospec") +var browserMock = require("../../test-utils/browserMock") +var callAsync = require("../../test-utils/callAsync") +o.spec("browserMock", function() { + + var $window + o.beforeEach(function() { + $window = browserMock() + }) + + o("Mocks DOM, pushState and XHR", function() { + o($window.location).notEquals(undefined) + o($window.document).notEquals(undefined) + o($window.XMLHttpRequest).notEquals(undefined) + }) + o("$window.onhashchange can be reached from the pushStateMock functions", function(done) { + $window.onhashchange = o.spy() + $window.location.hash = '#a' + + callAsync(function(){ + o($window.onhashchange.callCount).equals(1) + done() + }) + }) + o("$window.onpopstate can be reached from the pushStateMock functions", function() { + $window.onpopstate = o.spy() + $window.history.pushState(null, null, "#a") + $window.history.back() + + o($window.onpopstate.callCount).equals(1) + }) + o("$window.onunload can be reached from the pushStateMock functions", function() { + $window.onunload = o.spy() + $window.location.href = '/a' + + o($window.onunload.callCount).equals(1) + }) +})