Skip to content

Error handling and recovery #2273

Closed
Closed
@dead-claudia

Description

@dead-claudia

Mithril currently doesn't have any error handling story. This is of course really bad.

Here's the concerns I believe need addressed when it comes to fault tolerance, in decreasing priority:

  • Mithril shouldn't break over user errors
  • Mithril shouldn't hide user errors
  • Mithril shouldn't let caught user errors affect unrelated components
  • Mithril shouldn't allow potentially invalid state to be used
  • Mithril shouldn't let resource leaks propagate
  • Mithril shouldn't prevent error recovery

The first two are adequately covered in #1937, but the other 4 are the focus of this bug: helping users write error-tolerant apps.


Edit: Update this for clarity, remove some special semantics.

My proposal is this: add a new error handling sequence algorithm to address all errors, with an associated optional hook onerror: function (vnode, error) { ... } usable on both components and vnodes.

The error handling sequence is as follows:

  1. Find the first parent with an onerror hook. If no such parent exists, use the root.
  2. Create a reference to the initial error. This is the current error.
  3. Clear the parent's children, calling onremove as appropriate, but not onbeforeremove. If any hook throws, the hook sets the current error to it and otherwise ignores the error.
  4. If the parent is the root, rethrow the current error.
  5. Invoke its onerror hook with its vnode and the current error.
  6. If the hook throws:
    1. Set the current error to the thrown error.
    2. Set the parent to its parent. If no such parent exists, set it to the root.
    3. Go to step 2.
In code, it would look something like this:
// Where `parentPath` is a linked list of error handlers for this vnode.
function handleError(path, error) {
	while (path != null) {
		var lastError = {value: error}
		onremoveChildren(path.children, lastError)
		if (path.vnode == null) throw lastError.value
		if (typeof path.vnode.tag !== "string" && typeof path.vnode.state.onerror === "function") {
			try {
				callHook.call(path.vnode.state.onerror, vnode)
				return
			} catch (e) {
				lastError.value = e
			}
		}
		if (path.vnode.attrs && typeof path.vnode.attrs.onerror === "function") {
			try {
				callHook.call(path.vnode.attrs.onerror, vnode)
				return
			} catch (e) {
				lastError.value = e
			}
		}
		path = path.parent
	}
}

// `onremove` gets fixed to this
function onremoveChildren(children, lastError) {
	for (var i = 0; i < children.length; i++) {
		if (children[i] != null) onremove(children[i])
	}
}
function onremove(vnode, lastError) {
	if (vnode.attrs && typeof vnode.attrs.onremove === "function") {
		try {
			callHook.call(vnode.attrs.onremove, vnode)
		} catch (e) {
			lastError.value = e
		}
	}
	if (typeof vnode.tag !== "string") {
		if (typeof vnode.state.onremove === "function") {
			try {
				callHook.call(vnode.state.onremove, vnode)
			} catch (e) {
				lastError.value = e
			}
		}
		if (vnode.instance != null) onremove(vnode.instance)
	} else if (Array.isArray(vnode.children)) {
		onremoveChildren(vnode.children, lastError)
	}
}

Here's when the error handling sequence is executed:

  • When creating a child component throws.
  • When a child vnode's or component's hook throws, including their view or onerror.
  • When an onevent handler (like onclick) on a child DOM vnode throws.
  • Child vnodes include those returned from the current component's view - component boundaries are ignored here.

When it fires, it receives the current vnode (not the origin) and the thrown error. It may choose to do one of two things:

  • Rethrow the error to propagate it.
  • Return normally to signify recovery.

Here's a couple concrete example of this in action:

// Here's a component that logs errors and reinitializes its tree on error.
var RestartOnError = {
    onerror: function (v, e) { console.error(e) },
	view: function (v) { return v.attrs.view() },
}

// Here's how you'd use it:
m(RestartOnError, {view: function () {
	return m(ComponentThatLikesToError)
}})

// Here's a component that swaps a view on error:
function SwapViewOnError() {
	var error = false
	return {
	    onerror: function (v, e) { error = true; console.error(e) },
		view: function (v) {
			if (!error) {
				try {
					return v.attrs.view()
				} catch (e) {
					console.error(e)
					error = true
				}
			}
			return v.attrs.errorView()
		},
	}
}

// Here's how you'd use it:
m(SwapViewOnError, {
	view: function () { return m(ComponentThatLikesToError) },
	errorView: function () { return "Something bad happened..." },
})

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: CoreFor anything dealing with Mithril core itselfType: EnhancementFor any feature request or suggestion that isn't a bug fix

    Type

    No type

    Projects

    Status

    Completed/Declined

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions