Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Error Boundaries #181

Open
raquo opened this issue Dec 4, 2024 · 0 comments
Open

Error Boundaries #181

raquo opened this issue Dec 4, 2024 · 0 comments

Comments

@raquo
Copy link
Owner

raquo commented Dec 4, 2024

Many UI libraries have a feature that lets you contain errors / exceptions to within a component, preventing the errors from bringing down the entire application. Conceptually, this acts as a sort of try-catch block in your element tree, except it catches exceptions not just from the initial element creation, but also from the subsequent lifetime of that element.

Airstream of course has a bunch of error handling operators that can limit the propagation of errors through the observable graph, but having proper error boundaries in the element-tree plane would reduce the error handling boilerplate and especially help handle unexpected errors.

Notes to self, and assorted issues:

Here's an overview of (approximately!) how it could be implemented and used in Laminar:

trait ErrorContext {
   def error: Option[Throwable]
   
   // call this to manually trigger the ErrorBoundary that this context belongs to
   def fail(err: Throwable): Unit

   // calls `render` again, trying to render the component afresh
   def reload(): Unit
   
  // renders another element (an error message) instead of whatever was rendered previously
   def fallback(el: Element): Unit
}

ErrorBoundary(
  render = ctx => {
    div(...) // render component
  },
  onError = (ctx, err) => {
    // perform any side effect such as logging if needed
    // call ctx.fallback to render the error, or ctx.reload to try to render the main component again
    ctx.fallback(div("An error occurred: ${ctx.error}"))
  },
)

The resulting ErrorBoundary would be a DynamicInserter on its own, i.e. an equivalent of child <-- elementSignal, but may also expose a Signal[Element] that contains the result of either render or fallback, whichever is currently active.

We will also need some way to handle errors that happen in onError callback, perhaps similarly to how Observer does it. But make sure to avoid an infinite loop, especially if calling ctx.reload()

How will the ErrorBoundary detect errors?

  • It will somehow register with the element that it is rendering
  • When any Laminar modifier detects an error that was allowed to reach the DOM unhandled (e.g. a child <-- signal where the signal emits an error instead of an element), it will report this error up the element tree until it finds a registered error boundary – the boundary will then handle the error instead of allowing it to propagate higher up.
  • This will also prevent the offending error from being reported to Airstream unhandled (unless it gets there some other way), as it is handled now.
  • To make the coverage more complete, the users should be able to wire error triggering manually. For example, they could call ctx.fail(err) manually whenever they face an error that they want to handle with the error boundary.
  • We should also make some helpers for observables and perhaps observers to redirect their errors to the error boundary. For example, if you have a component that exposes a stream in addition to the element, you may want to also guard the stream from errors, to prevent the component's errors from propagating outwards. Of course there's .ignoreErrors, but I mean, something like a blockErrorsAndReportToBoundary(errorCtx) operator.
  • Actually, will the API that I'm envisioning work for components that expose more than just an element? Will they be able to get a reference to errorCtx where they need it? Not sure. Try it out...

Because Airstream errors multiply, it's possible that an ErrorBoundary will receive multiple errors due to the same underlying issue, as it makes its way to the tripping points – and those errors may not even all be the same ones, as some airstream operators can transform errors (e.g. combineWith, or recover*). We need a way to avoid triggering the onError logic redundantly, perhaps batch the errors, or report the first one and ignore the rest. How does the timing on this work? – if we unmount the offending element as soon as we get the first element, do we expect to keep receiving more errors as the transaction propagates, or will Airstream cut off other observers before they get a chance to fire? I forget, and we'll need to add a test for that, to make sure that Airstream changes don't break that.

So, ErrorBoundary would wrap an Element and essentially return a DynamicInserter, which is less useful type – for example, we can't call .amend on it. We already have a bunch of other issues that discuss the concept of Resource-s, so I won't rehash that here. In the initial version, I don't foresee adding any helper methods to provide resource-like functionality (other than perhaps exposing the signal of the element)

ErrorBoundary will have a mechanism to re-render the component. It can't do anything about its inputs fleeting away. For example, if the component depended on a stream – if that stream won't emit an event again, then the component won't receive it, and may be stuck rendering some kind of "loading" state. This is why you get to run any side effects in the onError callback – if you need to re-trigger any network requests or otherwise fix any inputs, you can do that before or after calling ctx.reload, whatever makes sense.

In my code snippet, ctx is ErrorContext – that is a stateful / mutable data type that reflects the current state of the boundary. When we provide ctx to onError, we KNOW that there is an error right now, yet ctx only gives us an Option of an error. So, we probably need err as the second argument. I was hoping to use single-argument callbacks to avoid the same source-compatibility issues as we're facing with the split callback, but I don't think there's a safe way around this here.

Consider whether onError should be a partial function instead, letting users match on the error type (and if not caught, then I guess the resulting signal would emit that error, propagating it up the element tree?) Is this any different from just throwing an exception in onError handler?

Consider whether there should be any difference between calling ctx.reload() and ctx.falback(sameRenderMethod(ctx)).

Consider the type inference aspect of the API. If ErrorBoundary exposes a Signal[Element] – what type of Element that would that be, exactly? It has to be the supertype of anything that ErrorBoundary is capable of rendering, and yet the way the API is drafted, with the fallback callback, that could be anything. But, if ErrorBoundary had a type param for the element type, its type would be inferred by the return value of the main render callback – so, most likely, a Div? And if I want to fallback to something other than a Div, for example to an image or svg or a web component, I would need to specify the type manually? May need to reconsider the API design...

If we want to expose the elements as a signal – consider that the errors are likely to happen inside a transaction, and that Var updates would be delayed until the end of the transaction. Laminar lifecycle events (onMount / onUnmount) work outside of Airstream / transacitons for this reason. So, perhaps it's best to work on raw callbacks internally, i.e. unmount the offending element immediately. I guess this would mean an implementation similar to ChildInserter – rather than being able to use ChildInserter internally. Think about that some more...


Overall, this seems rather complicated, but quite doable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant