diff --git a/api/mount-redraw.js b/api/mount-redraw.js index d51da61b..4f7d0f60 100644 --- a/api/mount-redraw.js +++ b/api/mount-redraw.js @@ -4,17 +4,15 @@ var Vnode = require("../render/vnode") module.exports = function(render, schedule, console) { var subscriptions = [] - var rendering = false var pending = false + var offset = -1 function sync() { - if (rendering) throw new Error("Nested m.redraw.sync() call") - rendering = true - for (var i = 0; i < subscriptions.length; i += 2) { - try { render(subscriptions[i], Vnode(subscriptions[i + 1]), redraw) } + for (offset = 0; offset < subscriptions.length; offset += 2) { + try { render(subscriptions[offset], Vnode(subscriptions[offset + 1]), redraw) } catch (e) { console.error(e) } } - rendering = false + offset = -1 } function redraw() { @@ -31,13 +29,14 @@ module.exports = function(render, schedule, console) { function mount(root, component) { if (component != null && component.view == null && typeof component !== "function") { - throw new TypeError("m.mount(element, component) expects a component, not a vnode") + throw new TypeError("m.mount expects a component, not a vnode.") } var index = subscriptions.indexOf(root) if (index >= 0) { subscriptions.splice(index, 2) - render(root, [], redraw) + if (index <= offset) offset -= 2 + render(root, []) } if (component != null) { diff --git a/api/router.js b/api/router.js index 45a6fe81..46e79998 100644 --- a/api/router.js +++ b/api/router.js @@ -33,16 +33,16 @@ module.exports = function($window, mountRedraw) { 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") + if (!root) throw new TypeError("DOM element being rendered to does not exist.") // 0 = start // 1 = init // 2 = ready var state = 0 var compiled = Object.keys(routes).map(function(route) { - if (route[0] !== "/") throw new SyntaxError("Routes must start with a `/`") + if (route[0] !== "/") throw new SyntaxError("Routes must start with a '/'.") if ((/:([^\/\.-]+)(\.{3})?:/).test(route)) { - throw new SyntaxError("Route parameter names must be separated with either `/`, `.`, or `-`") + throw new SyntaxError("Route parameter names must be separated with either '/', '.', or '-'.") } return { route: route, @@ -61,7 +61,7 @@ module.exports = function($window, mountRedraw) { var defaultData = parsePathname(defaultRoute) if (!compiled.some(function (i) { return i.check(defaultData) })) { - throw new ReferenceError("Default route doesn't match any known routes") + throw new ReferenceError("Default route doesn't match any known routes.") } } @@ -87,8 +87,8 @@ module.exports = function($window, mountRedraw) { assign(data.params, $window.history.state) - function fail() { - if (path === defaultRoute) throw new Error("Could not resolve default route " + defaultRoute) + function reject(e) { + console.error(e) setPath(defaultRoute, null, {replace: true}) } @@ -123,13 +123,17 @@ module.exports = function($window, mountRedraw) { else if (payload.onmatch) { p.then(function () { return payload.onmatch(data.params, path, matchedRoute) - }).then(update, fail) + }).then(update, path === defaultRoute ? null : reject) } else update("div") return } } - fail() + + if (path === defaultRoute) { + throw new Error("Could not resolve default route " + defaultRoute + ".") + } + setPath(defaultRoute, null, {replace: true}) } } @@ -157,12 +161,11 @@ module.exports = function($window, mountRedraw) { $window.addEventListener("hashchange", resolveRoute, false) } - return mountRedraw.mount(root, { + mountRedraw.mount(root, { onbeforeupdate: function() { state = state ? 2 : 1 return !(!state || sentinel === currentResolver) }, - oncreate: resolveRoute, onremove: onremove, view: function() { if (!state || sentinel === currentResolver) return @@ -172,6 +175,7 @@ module.exports = function($window, mountRedraw) { return vnode }, }) + resolveRoute() } route.set = function(path, data, options) { if (lastUpdate != null) { diff --git a/api/tests/test-mountRedraw.js b/api/tests/test-mountRedraw.js index 0c52379c..4027c1c3 100644 --- a/api/tests/test-mountRedraw.js +++ b/api/tests/test-mountRedraw.js @@ -91,6 +91,30 @@ o.spec("mount/redraw", function() { o(spy3.callCount).equals(2) }) + o("should not redraw when mounting another root", function() { + var el1 = $document.createElement("div") + var el2 = $document.createElement("div") + var el3 = $document.createElement("div") + var spy1 = o.spy() + var spy2 = o.spy() + var spy3 = o.spy() + + m.mount(el1, {view: spy1}) + o(spy1.callCount).equals(1) + o(spy2.callCount).equals(0) + o(spy3.callCount).equals(0) + + m.mount(el2, {view: spy2}) + o(spy1.callCount).equals(1) + o(spy2.callCount).equals(1) + o(spy3.callCount).equals(0) + + m.mount(el3, {view: spy3}) + o(spy1.callCount).equals(1) + o(spy2.callCount).equals(1) + o(spy3.callCount).equals(1) + }) + o("should stop running after mount null", function() { var spy = o.spy() @@ -204,6 +228,136 @@ o.spec("mount/redraw", function() { o(function() { m.mount(root, {}) }).throws(TypeError) }) + o("skips roots that were synchronously unsubscribed before they were visited", function() { + var calls = [] + var root1 = $document.createElement("div") + var root2 = $document.createElement("div") + var root3 = $document.createElement("div") + + m.mount(root1, { + onbeforeupdate: function() { + m.mount(root2, null) + }, + view: function() { calls.push("root1") }, + }) + m.mount(root2, {view: function() { calls.push("root2") }}) + m.mount(root3, {view: function() { calls.push("root3") }}) + o(calls).deepEquals([ + "root1", "root2", "root3", + ]) + + m.redraw.sync() + o(calls).deepEquals([ + "root1", "root2", "root3", + "root1", "root3", + ]) + }) + + o("keeps its place when synchronously unsubscribing previously visited roots", function() { + var calls = [] + var root1 = $document.createElement("div") + var root2 = $document.createElement("div") + var root3 = $document.createElement("div") + + m.mount(root1, {view: function() { calls.push("root1") }}) + m.mount(root2, { + onbeforeupdate: function() { + m.mount(root1, null) + }, + view: function() { calls.push("root2") }, + }) + m.mount(root3, {view: function() { calls.push("root3") }}) + o(calls).deepEquals([ + "root1", "root2", "root3", + ]) + + m.redraw.sync() + o(calls).deepEquals([ + "root1", "root2", "root3", + "root1", "root2", "root3", + ]) + }) + + o("keeps its place when synchronously unsubscribing previously visited roots in the face of errors", function() { + errors = ["fail"] + var calls = [] + var root1 = $document.createElement("div") + var root2 = $document.createElement("div") + var root3 = $document.createElement("div") + + m.mount(root1, {view: function() { calls.push("root1") }}) + m.mount(root2, { + onbeforeupdate: function() { + m.mount(root1, null) + throw "fail" + }, + view: function() { calls.push("root2") }, + }) + m.mount(root3, {view: function() { calls.push("root3") }}) + o(calls).deepEquals([ + "root1", "root2", "root3", + ]) + + m.redraw.sync() + o(calls).deepEquals([ + "root1", "root2", "root3", + "root1", "root3", + ]) + }) + + o("keeps its place when synchronously unsubscribing the current root", function() { + var calls = [] + var root1 = $document.createElement("div") + var root2 = $document.createElement("div") + var root3 = $document.createElement("div") + + m.mount(root1, {view: function() { calls.push("root1") }}) + m.mount(root2, { + onbeforeupdate: function() { + try { m.mount(root2, null) } catch (e) { calls.push([e.constructor, e.message]) } + }, + view: function() { calls.push("root2") }, + }) + m.mount(root3, {view: function() { calls.push("root3") }}) + o(calls).deepEquals([ + "root1", "root2", "root3", + ]) + + m.redraw.sync() + o(calls).deepEquals([ + "root1", "root2", "root3", + "root1", [TypeError, "Node is currently being rendered to and thus is locked."], "root2", "root3", + ]) + }) + + o("keeps its place when synchronously unsubscribing the current root in the face of an error", function() { + errors = [ + [TypeError, "Node is currently being rendered to and thus is locked."], + ] + var calls = [] + var root1 = $document.createElement("div") + var root2 = $document.createElement("div") + var root3 = $document.createElement("div") + + m.mount(root1, {view: function() { calls.push("root1") }}) + m.mount(root2, { + onbeforeupdate: function() { + try { m.mount(root2, null) } catch (e) { throw [e.constructor, e.message] } + }, + view: function() { calls.push("root2") }, + }) + m.mount(root3, {view: function() { calls.push("root3") }}) + o(calls).deepEquals([ + "root1", "root2", "root3", + ]) + + m.redraw.sync() + o(calls).deepEquals([ + "root1", "root2", "root3", + "root1", "root3", + ]) + }) + components.forEach(function(cmp){ o.spec(cmp.kind, function(){ var createComponent = cmp.create diff --git a/api/tests/test-router.js b/api/tests/test-router.js index 3989e5f6..1dd760ef 100644 --- a/api/tests/test-router.js +++ b/api/tests/test-router.js @@ -69,6 +69,9 @@ o.spec("route", function() { } } + // In case it doesn't get reset + var realError = console.error + o.beforeEach(function() { currentTest = nextID++ $window = browserMock(env) @@ -79,11 +82,16 @@ o.spec("route", function() { mountRedraw = apiMountRedraw(coreRenderer($window), throttleMock.schedule, console) route = apiRouter($window, mountRedraw) route.prefix = prefix + console.error = function() { + realError.call(this, new Error("Unexpected `console.error` call")) + realError.apply(this, arguments) + } }) o.afterEach(function() { o(throttleMock.queueLength()).equals(0) currentTest = -1 // doesn't match any test + console.error = realError }) o("throws on invalid `root` DOM node", function() { @@ -1082,11 +1090,13 @@ o.spec("route", function() { var matchCount = 0 var renderCount = 0 var spy = o.spy() + var error = new Error("error") + var errorSpy = console.error = o.spy() var resolver = { onmatch: lock(function() { matchCount++ - return Promise.reject(new Error("error")) + return Promise.reject(error) }), render: lock(function(vnode) { renderCount++ @@ -1104,6 +1114,8 @@ o.spec("route", function() { o(matchCount).equals(1) o(renderCount).equals(0) o(spy.callCount).equals(1) + o(errorSpy.callCount).equals(1) + o(errorSpy.args[0]).equals(error) }) }) diff --git a/docs/change-log.md b/docs/change-log.md index 2c2274db..d11d6ad8 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -17,6 +17,14 @@ ### Upcoming... +*Note for later: release as semver-minor.* + +- Improved error messages in multiple places. ([#2536](https://github.com/MithrilJS/mithril.js/pull/2536) [@isiahmeadows](https://github.com/isiahmeadows)) +- The redraw reentrancy check was moved from `m.mount` to `m.render` and its error message was updated accordingly. ([#2536](https://github.com/MithrilJS/mithril.js/pull/2536) [@isiahmeadows](https://github.com/isiahmeadows)) + - This is unlikely to break people because if you were to do it with `m.render` directly before now, you'd corrupt Mithril's internal representation and internal errors could occur as a result. Now, it just warns you. +- For a better debugging experience with `m.route` route resolvers, errors on `onmatch` in the default route are left unhandled and errors in `onmatch` in other routes are logged to the console before redirecting. ([#2536](https://github.com/MithrilJS/mithril.js/pull/2536) [@isiahmeadows](https://github.com/isiahmeadows)) +- Bug fix with `m.redraw` where if you removed a root that was previously visited in the current redraw pass, it would lose its place and skip the next root. + --> ### v2.0.4 diff --git a/ospec/ospec.js b/ospec/ospec.js index 854cbefb..66c5dc75 100644 --- a/ospec/ospec.js +++ b/ospec/ospec.js @@ -14,12 +14,12 @@ else window.o = m() } function o(subject, predicate) { if (predicate === undefined) { - if (!isRunning()) throw new Error("Assertions should not occur outside test definitions") + if (!isRunning()) throw new Error("Assertions should not occur outside test definitions.") return new Assert(subject) } else { - if (isRunning()) throw new Error("Test definitions and hooks shouldn't be nested. To group tests use `o.spec()`") + if (isRunning()) throw new Error("Test definitions and hooks shouldn't be nested. To group tests, use 'o.spec()'.") subject = String(subject) - if (subject.charCodeAt(0) === 1) throw new Error("test names starting with '\\x01' are reserved for internal use") + if (subject.charCodeAt(0) === 1) throw new Error("test names starting with '\\x01' are reserved for internal use.") ctx[unique(subject)] = new Task(predicate, ensureStackTrace(new Error)) } } @@ -28,9 +28,9 @@ else window.o = m() o.beforeEach = hook("\x01beforeEach") o.afterEach = hook("\x01afterEach") o.specTimeout = function (t) { - if (isRunning()) throw new Error("o.specTimeout() can only be called before o.run()") - if (hasOwn.call(ctx, "\x01specTimeout")) throw new Error("A default timeout has already been defined in this context") - if (typeof t !== "number") throw new Error("o.specTimeout() expects a number as argument") + if (isRunning()) throw new Error("o.specTimeout() can only be called before o.run().") + if (hasOwn.call(ctx, "\x01specTimeout")) throw new Error("A default timeout has already been defined in this context.") + if (typeof t !== "number") throw new Error("o.specTimeout() expects a number as argument.") ctx["\x01specTimeout"] = t } o.new = init @@ -139,7 +139,7 @@ else window.o = m() // public API, may only be called once from use code (or after returned Promise resolution) function done(err) { if (!isDone) isDone = true - else throw new Error("`" + arg + "()` should only be called once") + else throw new Error("'" + arg + "()' should only be called once.") if (timeout === undefined) console.warn("# elapsed: " + Math.round(new Date - s) + "ms, expected under " + delay + "ms\n" + o.cleanStackTrace(task.err)) finalizeAsync(err) } @@ -161,7 +161,7 @@ else window.o = m() }, Math.min(delay, 2147483647)) } function setDelay (t) { - if (typeof t !== "number") throw new Error("timeout() and o.timeout() expect a number as argument") + if (typeof t !== "number") throw new Error("timeout() and o.timeout() expect a number as argument.") delay = t } if (fn.length > 0) { @@ -169,7 +169,7 @@ else window.o = m() arg = (body.match(/^(.+?)(?:\s|\/\*[\s\S]*?\*\/|\/\/.*?\n)*=>/) || body.match(/\((?:\s|\/\*[\s\S]*?\*\/|\/\/.*?\n)*(.+?)(?:\s|\/\*[\s\S]*?\*\/|\/\/.*?\n)*[,\)]/) || []).pop() if (body.indexOf(arg) === body.lastIndexOf(arg)) { var e = new Error - e.stack = "`" + arg + "()` should be called at least once\n" + o.cleanStackTrace(task.err) + e.stack = "'" + arg + "()' should be called at least once\n" + o.cleanStackTrace(task.err) throw e } try { @@ -204,14 +204,14 @@ else window.o = m() } function unique(subject) { if (hasOwn.call(ctx, subject)) { - console.warn("A test or a spec named `" + subject + "` was already defined") + console.warn("A test or a spec named '" + subject + "' was already defined.") while (hasOwn.call(ctx, subject)) subject += "*" } return subject } function hook(name) { return function(predicate) { - if (ctx[name]) throw new Error("This hook should be defined outside of a loop or inside a nested test group:\n" + predicate) + if (ctx[name]) throw new Error(name.slice(1) + " should be defined outside of a loop or inside a nested test group.") ctx[name] = new Task(predicate, ensureStackTrace(new Error)) } } @@ -317,7 +317,7 @@ else window.o = m() try {return JSON.stringify(value)} catch (e) {return String(value)} } function noTimeoutRightNow() { - throw new Error("o.timeout must be called snchronously from within a test definition or a hook") + throw new Error("o.timeout must be called snchronously from within a test definition or a hook.") } var colorCodes = { red: "31m", diff --git a/ospec/tests/test-ospec.js b/ospec/tests/test-ospec.js index 060bc5a5..84d2a917 100644 --- a/ospec/tests/test-ospec.js +++ b/ospec/tests/test-ospec.js @@ -532,7 +532,7 @@ o.spec("ospec", function() { err = e } o(err instanceof Error).equals(true) - o(err.message).equals("`oodone()` should only be called once") + o(err.message).equals("'oodone()' should only be called once.") }) oo.run(function(results) { o(results.length).equals(1) @@ -551,7 +551,7 @@ o.spec("ospec", function() { err = e } o(err instanceof Error).equals(true) - o(err.message).equals("`oodone()` should only be called once") + o(err.message).equals("'oodone()' should only be called once.") }) oo.run(function(results) { o(results.length).equals(1) @@ -570,7 +570,7 @@ o.spec("ospec", function() { err = e } o(err instanceof Error).equals(true) - o(err.message).equals("`oodone()` should only be called once") + o(err.message).equals("'oodone()' should only be called once.") }) oo.run(function(results) { o(results.length).equals(1) @@ -590,7 +590,7 @@ o.spec("ospec", function() { err = e } o(err instanceof Error).equals(true) - o(err.message).equals("`oodone()` should only be called once") + o(err.message).equals("'oodone()' should only be called once.") }) oo.run(function(results) { o(results.length).equals(1) diff --git a/pathname/build.js b/pathname/build.js index 463e1237..d29c7a1f 100644 --- a/pathname/build.js +++ b/pathname/build.js @@ -6,7 +6,7 @@ var assign = require("./assign") // Returns `path` from `template` + `params` module.exports = function(template, params) { if ((/:([^\/\.-]+)(\.{3})?:/).test(template)) { - throw new SyntaxError("Template parameter names *must* be separated") + throw new SyntaxError("Template parameter names must be separated by either a '/', '-', or '.'.") } if (params == null) return template var queryIndex = template.indexOf("?") diff --git a/promise/polyfill.js b/promise/polyfill.js index 68cd60e1..a1c55721 100644 --- a/promise/polyfill.js +++ b/promise/polyfill.js @@ -1,8 +1,8 @@ "use strict" /** @constructor */ var PromisePolyfill = function(executor) { - if (!(this instanceof PromisePolyfill)) throw new Error("Promise must be called with `new`") - if (typeof executor !== "function") throw new TypeError("executor must be a function") + if (!(this instanceof PromisePolyfill)) throw new Error("Promise must be called with 'new'.") + if (typeof executor !== "function") throw new TypeError("executor must be a function.") var self = this, resolvers = [], rejectors = [], resolveCurrent = handler(resolvers, true), rejectCurrent = handler(rejectors, false) var instance = self._instance = {resolvers: resolvers, rejectors: rejectors} @@ -12,7 +12,7 @@ var PromisePolyfill = function(executor) { var then try { if (shouldAbsorb && value != null && (typeof value === "object" || typeof value === "function") && typeof (then = value.then) === "function") { - if (value === self) throw new TypeError("Promise can't be resolved w/ itself") + if (value === self) throw new TypeError("Promise can't be resolved with itself.") executeOnce(then.bind(value)) } else { diff --git a/render/render.js b/render/render.js index d7de912d..7e6e62f9 100644 --- a/render/render.js +++ b/render/render.js @@ -17,7 +17,7 @@ module.exports = function($window) { //sanity check to discourage people from doing `vnode.state = ...` function checkState(vnode, original) { - if (vnode.state !== original) throw new Error("`vnode.state` must not be modified") + if (vnode.state !== original) throw new Error("'vnode.state' must not be modified.") } //Note: the hook is passed as the `this` argument to allow proxying the @@ -618,7 +618,7 @@ module.exports = function($window) { var content = children[0].children if (vnode.dom.innerHTML !== content) vnode.dom.innerHTML = content } - else if (vnode.text != null || children != null && children.length !== 0) throw new Error("Child node of a contenteditable must be trusted") + else if (vnode.text != null || children != null && children.length !== 0) throw new Error("Child node of a contenteditable must be trusted.") return true } @@ -948,26 +948,33 @@ module.exports = function($window) { return true } + var currentDOM + return function(dom, vnodes, redraw) { - if (!dom) throw new TypeError("Ensure the DOM element being passed to m.route/m.mount/m.render is not undefined.") + if (!dom) throw new TypeError("DOM element being rendered to does not exist.") + if (currentDOM != null && dom.contains(currentDOM)) { + throw new TypeError("Node is currently being rendered to and thus is locked.") + } + var prevRedraw = currentRedraw + var prevDOM = currentDOM var hooks = [] var active = activeElement() var namespace = dom.namespaceURI - // First time rendering into a node clears it out - if (dom.vnodes == null) dom.textContent = "" - - vnodes = Vnode.normalizeChildren(Array.isArray(vnodes) ? vnodes : [vnodes]) - var prevRedraw = currentRedraw + currentDOM = dom + currentRedraw = typeof redraw === "function" ? redraw : undefined try { - currentRedraw = typeof redraw === "function" ? redraw : undefined + // First time rendering into a node clears it out + if (dom.vnodes == null) dom.textContent = "" + vnodes = Vnode.normalizeChildren(Array.isArray(vnodes) ? vnodes : [vnodes]) updateNodes(dom, dom.vnodes, vnodes, hooks, null, namespace === "http://www.w3.org/1999/xhtml" ? undefined : namespace) + dom.vnodes = vnodes + // `document.activeElement` can return null: https://html.spec.whatwg.org/multipage/interaction.html#dom-document-activeelement + if (active != null && activeElement() !== active && typeof active.focus === "function") active.focus() + for (var i = 0; i < hooks.length; i++) hooks[i]() } finally { currentRedraw = prevRedraw + currentDOM = prevDOM } - dom.vnodes = vnodes - // `document.activeElement` can return null: https://html.spec.whatwg.org/multipage/interaction.html#dom-document-activeelement - if (active != null && activeElement() !== active && typeof active.focus === "function") active.focus() - for (var i = 0; i < hooks.length; i++) hooks[i]() } } diff --git a/render/tests/test-render.js b/render/tests/test-render.js index 4976fd8c..7898c9a7 100644 --- a/render/tests/test-render.js +++ b/render/tests/test-render.js @@ -329,4 +329,65 @@ o.spec("render", function() { render(root.childNodes[0], [{tag: "g"}]) o(root.childNodes[0].childNodes[0].namespaceURI).equals("http://www.w3.org/2000/svg") }) + o("does not allow reentrant invocations", function() { + var thrown = [] + function A() { + var updated = false + try {render(root, {tag: A})} catch (e) {thrown.push("construct")} + return { + oninit: function() { + try {render(root, {tag: A})} catch (e) {thrown.push("oninit")} + }, + oncreate: function() { + try {render(root, {tag: A})} catch (e) {thrown.push("oncreate")} + }, + onbeforeupdate: function() { + try {render(root, {tag: A})} catch (e) {thrown.push("onbeforeupdate")} + }, + onupdate: function() { + if (updated) return + updated = true + try {render(root, {tag: A})} catch (e) {thrown.push("onupdate")} + }, + onbeforeremove: function() { + try {render(root, {tag: A})} catch (e) {thrown.push("onbeforeremove")} + }, + onremove: function() { + try {render(root, {tag: A})} catch (e) {thrown.push("onremove")} + }, + view: function() { + try {render(root, {tag: A})} catch (e) {thrown.push("view")} + }, + } + } + render(root, {tag: A}) + o(thrown).deepEquals([ + "construct", + "oninit", + "view", + "oncreate", + ]) + render(root, {tag: A}) + o(thrown).deepEquals([ + "construct", + "oninit", + "view", + "oncreate", + "onbeforeupdate", + "view", + "onupdate", + ]) + render(root, []) + o(thrown).deepEquals([ + "construct", + "oninit", + "view", + "oncreate", + "onbeforeupdate", + "view", + "onupdate", + "onbeforeremove", + "onremove", + ]) + }) }) diff --git a/render/vnode.js b/render/vnode.js index d027e9cf..ec19b174 100644 --- a/render/vnode.js +++ b/render/vnode.js @@ -18,7 +18,11 @@ Vnode.normalizeChildren = function(input) { // it, noticeably so. for (var i = 1; i < input.length; i++) { if ((input[i] != null && input[i].key != null) !== isKeyed) { - throw new TypeError("Vnodes must either always have keys or never have keys!") + throw new TypeError( + isKeyed && (input[i] != null || typeof input[i] === "boolean") + ? "In fragments, vnodes must either all have keys or none have keys. You may wish to consider using an explicit keyed empty fragment, m.fragment({key: ...}), instead of a hole." + : "In fragments, vnodes must either all have keys or none have keys." + ) } } for (var i = 0; i < input.length; i++) { diff --git a/stream/stream.js b/stream/stream.js index 359396dd..384c5a0d 100644 --- a/stream/stream.js +++ b/stream/stream.js @@ -96,7 +96,7 @@ function Stream(value) { function combine(fn, streams) { var ready = streams.every(function(s) { if (s.constructor !== Stream) - throw new Error("Ensure that each item passed to stream.combine/stream.merge/lift is a stream") + throw new Error("Ensure that each item passed to stream.combine/stream.merge/lift is a stream.") return s._state === "active" }) var stream = ready diff --git a/test-utils/domMock.js b/test-utils/domMock.js index 01407e31..e0d11572 100644 --- a/test-utils/domMock.js +++ b/test-utils/domMock.js @@ -304,6 +304,13 @@ module.exports = function(options) { parentNode: null, childNodes: [], attributes: {}, + contains: function(child) { + while (child != null) { + if (child === this) return true + child = child.parentNode + } + return false + }, get firstChild() { return this.childNodes[0] || null },