Description
Summary
Provide a mechanism to catch and handle exceptions thrown within a particular UI subtree.
Motivation and goals
Currently, we say that unhandled exceptions from components are fatal, because otherwise the circuit is left in an undefined state which could lead to stability or security problems in Blazor Server. There is a mechanism for displaying a terminal error UI to the end user, on by default in the template.
However many developers regard this as inadequate, leading to many requests for "global exception handling". The main point is that it's unrealistic to guarantee that no component throws from a lifecycle method or rendering, especially given large developer teams and use of 3rd-party component libraries. When inevitable unhandled exceptions occur, people are unsatisfied with:
- The UX being limited to "show an error and ask to reload, losing state". People want a more graceful form of error handling with as much recovery potential as possible.
- The limitation that all error UI logic has to be in JavaScript (because we refuse to run any more .NET code after the unhandled exception)
Other SPA frameworks support more granular mechanisms for global and semi-global error handling. We would like to do something inspired by the error boundaries feature in React.
Goals:
- Allow users to provide fallback UIs for select component subtrees in the event that there is an exception there
- Allow users to build apps with cleaner fallback experiences in the event of unexpected errors
- Give users more fine-grained control over how failures are handled in the app
Non-goals:
- Changing what it means to do global exception handling. We already have ILogger for logging, and beyond that, truly unhandled exceptions mean the app is in an undefined state which is unsafe to continue.
In scope
Defining one or more "error boundary" regions within .razor
code that handle exceptions from their child content. Each error boundary:
- ... may be nested
- ... can optionally define custom UI that will be shown if an error occurs. If not specified, we have a default UI. This needs to vary across rendering platforms (e.g., only web rendering can use HTML elements), and should be locale-independent.
- ... can support recovery. That is, when they catch an error, it's not fatal to the circuit. The user could navigate somewhere else and continue, or the error boundary itself could even switch back into a non-errored state and render new child content.
- ... log errors in a sensible way by default. Behaviors will vary across hosting models. For example on Blazor Server, always log detailed errors to the server
ILogger
, plus sendDetailedErrors
-respecting info to the client console.
Error boundaries are not expected to catch all possible exceptions. We're trying to cover ~90% of the most common scenarios, such as rendering and lifecycle methods. We are only trying to catch errors that we know for sure are recoverable (i.e., can't corrupt framework state). See the detailed design below for more info. In any other case, the existing mechanisms for terminating the circuit and showing the terminal error UI will continue to apply.
Out of scope
- Anything that creates new semantics for what kinds of errors are catchable/recoverable, or new application states. See the detailed design below for info on how to reason about this.
- Catching errors from outside the known, predefined, recoverable places. See detailed design.
- A mechanism for exceptions to bubble up to ancestor error boundaries. We don't need to recreate concepts like exception filters or rethrowing. It's enough to catch at the closest boundary.
Risks / unknowns
Risk: Developers might think that all possible exceptions will be caught and become recoverable. This doesn't put server stability at risk, but just means the developer will be surprised when the terminal error UI still can appear.
Risk: Developers might disagree with the design about which specific error types should be recoverable.
Risk: Developers might think they don't need to customize terminal error UI any more. It may be confusing that there are multiple different error UIs to customize (one per error boundary, and a final terminal one).
Risk: If errors are recoverable, unwise use of this feature could lead to an infinite error loop. For example, an error boundary might immediately try to re-render the same child content after an error, which immediately (or asynchronously, after some async I/O) throws again.
- Update: As a mitigation, the error boundary base component will attempt to detect infinite error loops by counting the number of errors. If it exceeds a reasonable number (which is configurable) then we'll treat it as fatal and stop.
Unknown: What happens if the error boundary itself has an unhandled error? See detailed design for a proposal. The risk here is that it's nonobvious.
Risk: If a large number of errors occur all at once within different error boundaries (e.g., if there's a separate boundary around each row in a grid, and every row throws), the log may be swamped with errors.
Risk: If we have to put in new try
/catch
blocks within the low-level rendering internals (and we will), this might adversely affect perf when running on WebAssembly, perhaps especially in AoT mode, because (IIRC) exceptions are implemented by calling out to JavaScript which then calls back into .NET. The cost would likely be paid by everyone, whether or not they are using error boundaries. We'll have to measure this.
Unknown: What are we putting in the default template? Should there be any error boundaries?
- Update: As per below, the templates will only include default CSS for error boundaries, but not error boundary components themselves. Developers need to take deliberate action to opt into allowing recovery from errors in specific places.
Risk: If there is an error boundary in the default template, or as soon as a developer adds one to their site, they are implicitly promising that any code they call from their lifecycle methods can tolerate unhandled exceptions. For example, any circuit-scoped model state no longer gets discarded at the first unhandled exception. What if it gets into a broken/illegal state and the circuit is still trying to use it? Developers need to understand what they are opting into. This is the same as with any state that spans multiple requests in MVC.
Risk: Naive developers might routinely mark all their components as being an error boundary and simply ignore exceptions. Again, this doesn't put framework internal stability at risk, but means the application will just not do anything sensible to notify about or log exceptions. Maybe we should put in some kind of roadblock to make error boundary components a special thing, not just a behavior you can trivially mix into any other component - not sure how though.
- Mitigation: Rather than letting just any component identify itself as an error boundary, the detailed design below proposes a mechanism for ensuring that error boundary components are separate things (but can still be subclassed for different rendering platforms).
Risk: Whatever default error UI we bake in, developers might take a dependency on its exact appearance and behaviors (e.g., resizing to fit content or not) forever. Will we ever be able to change the default error UI? Is it enough to document that the error UI should be customized and if you don't, you accept that it might just change in new framework versions? See detailed design for proposal.
- Mitigation: The default error UI will be an empty
<div>
, with style/contents provided purely via CSS in the template, so there's no back-compatibility concern.
Examples
Page-level error boundary in MainLayout.razor
:
<div class="main">
<div class="content px-4">
<ErrorBoundary @ref="errorBoundary">
@Body
</ErrorBoundary>
</div>
</div>
@code {
ErrorBoundary errorBoundary;
protected override void OnParametersSet()
{
// On each page navigation, reset any error state
errorBoundary?.Recover();
}
}
Error boundary around a component that sometimes fails due to bad data:
@foreach (var item in SomeItems)
{
<div class="item">
<ErrorBoundary @key="item">
<ChildContent>
<MyItemDisplay Item="@item" />
</ChildContent>
<ErrorContent>
<p class="my-error">Sorry, we can't display @item.ItemId due to a technical error</p>
</ErrorContext>
</ErrorBoundary>
</div>
}