Skip to content

Commit 3a3aea8

Browse files
Error boundaries (#30874)
1 parent d757db9 commit 3a3aea8

File tree

29 files changed

+1310
-38
lines changed

29 files changed

+1310
-38
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Runtime.ExceptionServices;
6+
using System.Threading.Tasks;
7+
8+
namespace Microsoft.AspNetCore.Components
9+
{
10+
/// <summary>
11+
/// A base class for error boundary components.
12+
/// </summary>
13+
public abstract class ErrorBoundaryBase : ComponentBase, IErrorBoundary
14+
{
15+
private int _errorCount;
16+
17+
/// <summary>
18+
/// The content to be displayed when there is no error.
19+
/// </summary>
20+
[Parameter] public RenderFragment? ChildContent { get; set; }
21+
22+
/// <summary>
23+
/// The content to be displayed when there is an error.
24+
/// </summary>
25+
[Parameter] public RenderFragment<Exception>? ErrorContent { get; set; }
26+
27+
/// <summary>
28+
/// The maximum number of errors that can be handled. If more errors are received,
29+
/// they will be treated as fatal. Calling <see cref="Recover"/> resets the count.
30+
/// </summary>
31+
[Parameter] public int MaximumErrorCount { get; set; } = 100;
32+
33+
/// <summary>
34+
/// Gets the current exception, or null if there is no exception.
35+
/// </summary>
36+
protected Exception? CurrentException { get; private set; }
37+
38+
/// <summary>
39+
/// Resets the error boundary to a non-errored state. If the error boundary is not
40+
/// already in an errored state, the call has no effect.
41+
/// </summary>
42+
public void Recover()
43+
{
44+
if (CurrentException is not null)
45+
{
46+
_errorCount = 0;
47+
CurrentException = null;
48+
StateHasChanged();
49+
}
50+
}
51+
52+
/// <summary>
53+
/// Invoked by the base class when an error is being handled. Typically, derived classes
54+
/// should log the exception from this method.
55+
/// </summary>
56+
/// <param name="exception">The <see cref="Exception"/> being handled.</param>
57+
protected abstract Task OnErrorAsync(Exception exception);
58+
59+
void IErrorBoundary.HandleException(Exception exception)
60+
{
61+
if (exception is null)
62+
{
63+
// This would be a framework bug if it happened. It should not be possible.
64+
throw new ArgumentNullException(nameof(exception));
65+
}
66+
67+
// If rendering the error content itself causes an error, then re-rendering on error risks creating an
68+
// infinite error loop. Unfortunately it's very hard to distinguish whether the error source is "child content"
69+
// or "error content", since the exceptions can be received asynchronously, arbitrarily long after we switched
70+
// between normal and errored states. Without creating a very intricate coupling between ErrorBoundaryBase and
71+
// Renderer internals, the obvious options are either:
72+
//
73+
// [a] Don't re-render if we're already in an error state. This is problematic because the renderer needs to
74+
// discard the error boundary's subtree on every error, in case a custom error boundary fails to do so, and
75+
// hence we'd be left with a blank UI if we didn't re-render.
76+
// [b] Do re-render each time, and trust the developer not to cause errors from their error content.
77+
//
78+
// As a middle ground, we try to detect excessive numbers of errors arriving in between recoveries, and treat
79+
// an excess as fatal. This also helps to expose the case where a child continues to throw (e.g., on a timer),
80+
// which would be very inefficient.
81+
if (++_errorCount > MaximumErrorCount)
82+
{
83+
ExceptionDispatchInfo.Capture(exception).Throw();
84+
}
85+
86+
// Notify the subclass so it can begin any async operation even before we render, because (for example)
87+
// we want logs to be written before rendering in case the rendering throws. But there's no reason to
88+
// wait for the async operation to complete before we render.
89+
var onErrorTask = OnErrorAsync(exception);
90+
if (!onErrorTask.IsCompletedSuccessfully)
91+
{
92+
_ = HandleOnErrorExceptions(onErrorTask);
93+
}
94+
95+
CurrentException = exception;
96+
StateHasChanged();
97+
}
98+
99+
private async Task HandleOnErrorExceptions(Task onExceptionTask)
100+
{
101+
if (onExceptionTask.IsFaulted)
102+
{
103+
// Synchronous error handling exceptions can simply be fatal to the circuit
104+
ExceptionDispatchInfo.Capture(onExceptionTask.Exception!).Throw();
105+
}
106+
else
107+
{
108+
// Async exceptions are tricky because there's no natural way to bring them back
109+
// onto the sync context within their original circuit. The closest approximation
110+
// we have is trying to rethrow via rendering. If, in the future, we add an API for
111+
// directly dispatching an exception from ComponentBase, we should use that here.
112+
try
113+
{
114+
await onExceptionTask;
115+
}
116+
catch (Exception exception)
117+
{
118+
CurrentException = exception;
119+
ChildContent = _ => ExceptionDispatchInfo.Capture(exception).Throw();
120+
ErrorContent = _ => _ => ExceptionDispatchInfo.Capture(exception).Throw();
121+
StateHasChanged();
122+
}
123+
}
124+
}
125+
}
126+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
6+
namespace Microsoft.AspNetCore.Components
7+
{
8+
// Purpose of this interface, instead of just using ErrorBoundaryBase directly:
9+
//
10+
// [1] It keeps clear what is fundamental to an error boundary from the Renderer's perspective.
11+
// Anything more specific than this is just a useful pattern inside ErrorBoundaryBase.
12+
// [2] It improves linkability. If an application isn't using error boundaries, then all of
13+
// ErrorBoundaryBase and its dependencies can be linked out, leaving only this interface.
14+
//
15+
// If we wanted, we could make this public, but it could lead to common antipatterns such as
16+
// routinely marking all components as error boundaries (e.g., in a common base class) in an
17+
// attempt to create "On Error Resume Next"-type behaviors.
18+
19+
internal interface IErrorBoundary
20+
{
21+
void HandleException(Exception error);
22+
}
23+
}

src/Components/Components/src/PublicAPI.Unshipped.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ Microsoft.AspNetCore.Components.ComponentApplicationState.PersistAsJson<TValue>(
99
Microsoft.AspNetCore.Components.ComponentApplicationState.PersistState(string! key, byte[]! value) -> void
1010
Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakeAsJson<TValue>(string! key, out TValue? instance) -> bool
1111
Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakePersistedState(string! key, out byte[]? value) -> bool
12+
Microsoft.AspNetCore.Components.ErrorBoundaryBase
13+
Microsoft.AspNetCore.Components.ErrorBoundaryBase.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment?
14+
Microsoft.AspNetCore.Components.ErrorBoundaryBase.ChildContent.set -> void
15+
Microsoft.AspNetCore.Components.ErrorBoundaryBase.CurrentException.get -> System.Exception?
16+
Microsoft.AspNetCore.Components.ErrorBoundaryBase.ErrorBoundaryBase() -> void
17+
Microsoft.AspNetCore.Components.ErrorBoundaryBase.ErrorContent.get -> Microsoft.AspNetCore.Components.RenderFragment<System.Exception!>?
18+
Microsoft.AspNetCore.Components.ErrorBoundaryBase.ErrorContent.set -> void
19+
Microsoft.AspNetCore.Components.ErrorBoundaryBase.MaximumErrorCount.get -> int
20+
Microsoft.AspNetCore.Components.ErrorBoundaryBase.MaximumErrorCount.set -> void
21+
Microsoft.AspNetCore.Components.ErrorBoundaryBase.Recover() -> void
1222
Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime
1323
Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.ComponentApplicationLifetime(Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime!>! logger) -> void
1424
Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.PersistStateAsync(Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer) -> System.Threading.Tasks.Task!
@@ -33,6 +43,7 @@ Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute
3343
Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute.CascadingTypeParameterAttribute(string! name) -> void
3444
Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute.Name.get -> string!
3545
Microsoft.AspNetCore.Components.RenderTree.Renderer.GetEventArgsType(ulong eventHandlerId) -> System.Type!
46+
abstract Microsoft.AspNetCore.Components.ErrorBoundaryBase.OnErrorAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
3647
override Microsoft.AspNetCore.Components.LayoutComponentBase.SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task!
3748
static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary<string!, object?>! parameters) -> Microsoft.AspNetCore.Components.ParameterView
3849
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs) -> System.Threading.Tasks.Task!

0 commit comments

Comments
 (0)