Make errors and their messages more accurate and helpful (#2536)

Also, I normalized them to all be sentences for consistency, and I moved
the reentrancy check from `m.mount` to `m.render` to be a little more
helpful. The router change during mounting is inconsequential and only
to avoid the new modified error, and the change to the update loop is to
send the original error if an error occurred while initializing the
default route. (This is all around more useful anyways.)

And while I was at it, I fixed an obscure bug with sync redraws.
This commit is contained in:
Isiah Meadows 2019-09-30 16:08:04 -04:00 committed by GitHub
parent 475747800a
commit b98ab29efd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 310 additions and 54 deletions

View file

@ -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) {

View file

@ -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) {

View file

@ -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

View file

@ -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)
})
})

View file

@ -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

View file

@ -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",

View file

@ -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)

View file

@ -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("?")

View file

@ -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 {

View file

@ -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]()
}
}

View file

@ -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",
])
})
})

View file

@ -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++) {

View file

@ -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

View file

@ -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
},