diff --git a/README.md b/README.md index fb4ef543..83cfae97 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Mithril's `config` method is now replaced by several lifecycle methods to improv ## Robustness -There are over 2600 assertions in the test suite, and tests cover even difficult-to-test things like `location.href`, `element.innerHTML` and `XMLHttpRequest` usage. +There are over 2700 assertions in the test suite, and tests cover even difficult-to-test things like `location.href`, `element.innerHTML` and `XMLHttpRequest` usage. ## Modularity diff --git a/api/tests/index.html b/api/tests/index.html index 649829c9..cd27120d 100644 --- a/api/tests/index.html +++ b/api/tests/index.html @@ -11,6 +11,7 @@ + diff --git a/api/tests/test-router.js b/api/tests/test-router.js index 832488e7..2c2dd8de 100644 --- a/api/tests/test-router.js +++ b/api/tests/test-router.js @@ -1,6 +1,7 @@ "use strict" var o = require("../../ospec/ospec") +var callAsync = require("../../test-utils/callAsync") var pushStateMock = require("../../test-utils/pushStateMock") var domMock = require("../../test-utils/domMock") @@ -28,7 +29,7 @@ o.spec("route", function() { route = apiRouter($window, coreRenderer($window), redraw) }) - o("renders into `root`", function() { + o("renders into `root`", function(done) { route(root, "/", { "/" : { view: function() { @@ -37,7 +38,11 @@ o.spec("route", function() { } }) - o(root.firstChild.nodeName).equals("DIV") + callAsync(function() { + o(root.firstChild.nodeName).equals("DIV") + + done() + }) }) o("redraws when render function is executed", function(done) { @@ -55,19 +60,21 @@ o.spec("route", function() { } }) - o(oninit.callCount).equals(1) + callAsync(function() { + o(oninit.callCount).equals(1) - redraw.publish() + redraw.publish() - // Wrapped to give time for the rate-limited redraw to fire - setTimeout(function() { - o(onupdate.callCount).equals(1) + // Wrapped to give time for the rate-limited redraw to fire + setTimeout(function() { + o(onupdate.callCount).equals(1) - done() - }, FRAME_BUDGET) + done() + }, FRAME_BUDGET) + }) }) - o("redraws on events", function(done, timeout) { + o("redraws on events", function(done) { var onupdate = o.spy() var oninit = o.spy() var onclick = o.spy() @@ -87,21 +94,23 @@ o.spec("route", function() { } }) - root.firstChild.dispatchEvent(e) + callAsync(function() { + root.firstChild.dispatchEvent(e) - o(oninit.callCount).equals(1) + o(oninit.callCount).equals(1) - o(onclick.callCount).equals(1) - o(onclick.this).equals(root.firstChild) - o(onclick.args[0].type).equals("click") - o(onclick.args[0].target).equals(root.firstChild) + o(onclick.callCount).equals(1) + o(onclick.this).equals(root.firstChild) + o(onclick.args[0].type).equals("click") + o(onclick.args[0].target).equals(root.firstChild) - // Wrapped to give time for the rate-limited redraw to fire - setTimeout(function() { - o(onupdate.callCount).equals(1) + // Wrapped to give time for the rate-limited redraw to fire + setTimeout(function() { + o(onupdate.callCount).equals(1) - done() - }, FRAME_BUDGET) + done() + }, FRAME_BUDGET) + }) }) o("event handlers can skip redraw", function(done) { @@ -126,19 +135,21 @@ o.spec("route", function() { } }) - root.firstChild.dispatchEvent(e) + callAsync(function() { + root.firstChild.dispatchEvent(e) - o(oninit.callCount).equals(1) + o(oninit.callCount).equals(1) - // Wrapped to ensure no redraw fired - setTimeout(function() { - o(onupdate.callCount).equals(0) + // Wrapped to ensure no redraw fired + setTimeout(function() { + o(onupdate.callCount).equals(0) - done() - }, FRAME_BUDGET) + done() + }, FRAME_BUDGET) + }) }) - o("changes location on route.link", function() { + o("changes location on route.link", function(done) { var e = $window.document.createEvent("MouseEvents") e.initEvent("click", true, true) @@ -161,10 +172,14 @@ o.spec("route", function() { } }) - o($window.location.href).equals("http://localhost/?/") + callAsync(function() { + o($window.location.href).equals("http://localhost/?/") - root.firstChild.dispatchEvent(e) + root.firstChild.dispatchEvent(e) - o($window.location.href).equals("http://localhost/?/test") + o($window.location.href).equals("http://localhost/?/test") + + done() + }) }) }) diff --git a/mithril.js b/mithril.js index 26485e13..2b53fefe 100644 --- a/mithril.js +++ b/mithril.js @@ -16,8 +16,8 @@ function initStream(stream, args) { stream.constructor = createStream stream._state = {id: guid++, value: undefined, error: undefined, state: 0, derive: undefined, recover: undefined, deps: {}, parents: [], errorStream: undefined, endStream: undefined} stream.map = map, stream.ap = ap, stream.of = createStream - stream.valueOf = valueOf - stream.catch = doCatch + stream.valueOf = valueOf, stream.toJSON = toJSON + stream.run = run, stream.catch = doCatch Object.defineProperties(stream, { error: {get: function() { @@ -46,15 +46,18 @@ function initStream(stream, args) { }) } function updateStream(stream, value, error) { - if (!absorbStream(stream, value, false) && !absorbStream(stream, error, true)) { - updateState(stream, value, error) - for (var id in stream._state.deps) updateDependency(stream._state.deps[id], false) - finalize(stream) - } + updateState(stream, value, error) + for (var id in stream._state.deps) updateDependency(stream._state.deps[id], false) + finalize(stream) } function updateState(stream, value, error) { + error = unwrapError(value, error) if (error !== undefined && typeof stream._state.recover === "function") { - try {updateValues(stream, stream._state.recover(), undefined)} + try { + var recovered = stream._state.recover() + if (recovered === HALT) return + updateValues(stream, recovered, undefined) + } catch (e) {updateValues(stream, undefined, e)} } else updateValues(stream, value, error) @@ -73,7 +76,8 @@ function updateDependency(stream, mustSync) { else { try { var value = state.derive() - if (!absorbStream(stream, value)) updateState(stream, value, undefined) + if (value === HALT) return + updateState(stream, value, undefined) } catch (e) { updateState(stream, undefined, e) @@ -81,29 +85,28 @@ function updateDependency(stream, mustSync) { } } } -function absorbStream(stream, value, isError) { +function unwrapError(value, error) { if (value != null && value.constructor === createStream) { - if (value._state.state === 2) { - stream.end(true) - stream(value()) - } - else if (value._state.error) stream.error(value.error()) - else if (value._state.state === 0) return true - else if (!isError) stream(value()) - else stream.error(value()) - return true + if (value._state.error !== undefined) error = value._state.error + else error = unwrapError(value._state.value, value._state.error) } - return false + return error } function finalize(stream) { stream._state.changed = false for (var id in stream._state.deps) stream._state.deps[id]._state.changed = false } +function run(fn) { + var self = createStream(), stream = this + return initDependency(self, [stream], function() { + return absorb(self, fn(stream())) + }, undefined) +} function doCatch(fn) { - var stream = this + var self = createStream(), stream = this var derive = function() {return stream._state.value} - var recover = function() {return fn(stream._state.error)} - return initDependency(createStream(), [stream], derive, recover) + var recover = function() {return absorb(self, fn(stream._state.error))} + return initDependency(self, [stream], derive, recover) } function combine(fn, streams) { return initDependency(createStream(), streams, function() { @@ -112,6 +115,16 @@ function combine(fn, streams) { return fn.apply(this, streams.concat([streams.filter(changed)])) }, undefined) } +function absorb(stream, value) { + if (value != null && value.constructor === createStream) { + value.error.map(stream.error) + value.map(stream) + if (value._state.state === 0) return HALT + if (value._state.error) throw value._state.error + value = value._state.value + } + return value +} function initDependency(dep, streams, derive, recover) { var state = dep._state state.derive = derive @@ -145,6 +158,7 @@ function unregisterStream(stream) { function map(fn) {return combine(function(stream) {return fn(stream())}, [this])} function ap(stream) {return combine(function(s1, s2) {return s1()(s2())}, [this, stream])} function valueOf() {return this._state.value} +function toJSON() {return JSON.stringify(this._state.value)} function active(stream) {return stream._state.state === 1} function changed(stream) {return stream._state.changed} function notEnded(stream) {return stream._state.state !== 2} @@ -154,7 +168,7 @@ function reject(e) { stream.error(e) return stream } -var Stream = {stream: createStream, combine: combine, reject: reject} +var Stream = {stream: createStream, combine: combine, reject: reject, HALT: HALT} function Node(tag, key, attrs, children, text, dom) { return {tag: tag, key: key, attrs: attrs, children: children, text: text, dom: dom, domSize: undefined, state: {}, events: undefined, instance: undefined} } @@ -782,7 +796,11 @@ var requestService = function($window) { stream(response) } - else stream.error(new Error(xhr.responseText)) + else { + var error = new Error(xhr.responseText) + for (var key in response) error[key] = response[key] + stream.error(error) + } } catch (e) { stream.error(e) @@ -892,6 +910,7 @@ var parseQueryString = function(string) { } var coreRouter = function($window) { var supportsPushState = typeof $window.history.pushState === "function" && $window.location.protocol !== "file:" + var callAsync = typeof setImmediate === "function" ? setImmediate : setTimeout var prefix = "#!" function setPrefix(value) {prefix = value} function normalize(fragment) { @@ -939,7 +958,7 @@ var coreRouter = function($window) { if (supportsPushState) { if (options && options.replace) $window.history.replaceState(null, null, prefix + path) else $window.history.pushState(null, null, prefix + path) - $window.onpopstate() + callAsync($window.onpopstate) } else $window.location.href = prefix + path } @@ -991,7 +1010,7 @@ var throttle = function(callback) { } else if (pending === null) { pending = timeout(function() { - pending = 0 + pending = null callback() last = new Date().getTime() }, time - (now - last)) diff --git a/router/router.js b/router/router.js index cd2cdac6..a6054424 100644 --- a/router/router.js +++ b/router/router.js @@ -5,6 +5,7 @@ var parseQueryString = require("../querystring/parse") module.exports = function($window) { var supportsPushState = typeof $window.history.pushState === "function" && $window.location.protocol !== "file:" + var callAsync = typeof setImmediate === "function" ? setImmediate : setTimeout var prefix = "#!" function setPrefix(value) {prefix = value} @@ -60,7 +61,7 @@ module.exports = function($window) { if (supportsPushState) { if (options && options.replace) $window.history.replaceState(null, null, prefix + path) else $window.history.pushState(null, null, prefix + path) - $window.onpopstate() + callAsync($window.onpopstate) } else $window.location.href = prefix + path } diff --git a/router/tests/index.html b/router/tests/index.html index 330a2391..cdacf44b 100644 --- a/router/tests/index.html +++ b/router/tests/index.html @@ -6,6 +6,7 @@
+ diff --git a/router/tests/test-defineRoutes.js b/router/tests/test-defineRoutes.js index 01b0f52d..8c4f5055 100644 --- a/router/tests/test-defineRoutes.js +++ b/router/tests/test-defineRoutes.js @@ -17,6 +17,13 @@ o.spec("Router.defineRoutes", function() { onFail = o.spy() }) + o("calls onRouteChange on init", function() { + $window.location.href = prefix + "/a" + router.defineRoutes({"/a": {data: 1}}, onRouteChange, onFail) + + o(onRouteChange.callCount).equals(1) + }) + o("resolves to route", function() { $window.location.href = prefix + "/test" router.defineRoutes({"/test": {data: 1}}, onRouteChange, onFail) diff --git a/router/tests/test-setPath.js b/router/tests/test-setPath.js index 69381c04..a9ef6450 100644 --- a/router/tests/test-setPath.js +++ b/router/tests/test-setPath.js @@ -1,6 +1,7 @@ "use strict" var o = require("../../ospec/ospec") +var callAsync = require("../../test-utils/callAsync") var pushStateMock = require("../../test-utils/pushStateMock") var Router = require("../../router/router") @@ -17,6 +18,28 @@ o.spec("Router.setPath", function() { onFail = o.spy() }) + o("setPath calls onRouteChange asynchronously", function(done) { + $window.location.href = prefix + "/a" + router.defineRoutes({"/a": {data: 1}, "/b": {data: 2}}, onRouteChange, onFail) + router.setPath("/b") + + o(onRouteChange.callCount).equals(1) + callAsync(function() { + o(onRouteChange.callCount).equals(2) + done() + }) + }) + o("setPath calls onFail asynchronously", function(done) { + $window.location.href = prefix + "/a" + router.defineRoutes({"/a": {data: 1}, "/b": {data: 2}}, onRouteChange, onFail) + router.setPath("/c") + + o(onFail.callCount).equals(0) + callAsync(function() { + o(onFail.callCount).equals(1) + done() + }) + }) o("sets route via API", function() { $window.location.href = prefix + "/test" router.defineRoutes({"/test": {data: 1}, "/other/:a/:b...": {data: 2}}, onRouteChange, onFail) diff --git a/test-utils/tests/index.html b/test-utils/tests/index.html index bad33bac..4e5a6234 100644 --- a/test-utils/tests/index.html +++ b/test-utils/tests/index.html @@ -13,6 +13,7 @@ + diff --git a/test-utils/tests/test-callAsync.js b/test-utils/tests/test-callAsync.js new file mode 100644 index 00000000..37d6defd --- /dev/null +++ b/test-utils/tests/test-callAsync.js @@ -0,0 +1,25 @@ +"use strict" + +var o = require("../../ospec/ospec") +var callAsync = require("../../test-utils/callAsync") + +o.spec("callAsync", function() { + o("works", function(done) { + var count = 0 + callAsync(function() { + o(count).equals(1) + done() + }) + count++ + }) + o("gets called before setTimeout", function(done) { + var timeout + callAsync(function() { + clearTimeout(timeout) + done() + }) + timeout = setTimeout(function() { + throw new Error("callAsync was called too slow") + }, 0) + }) +}) \ No newline at end of file