diff --git a/src/Components/Components/src/ErrorBoundaryBase.cs b/src/Components/Components/src/ErrorBoundaryBase.cs
new file mode 100644
index 000000000000..f35441db7e8d
--- /dev/null
+++ b/src/Components/Components/src/ErrorBoundaryBase.cs
@@ -0,0 +1,126 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Runtime.ExceptionServices;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.Components
+{
+ ///
+ /// A base class for error boundary components.
+ ///
+ public abstract class ErrorBoundaryBase : ComponentBase, IErrorBoundary
+ {
+ private int _errorCount;
+
+ ///
+ /// The content to be displayed when there is no error.
+ ///
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+ ///
+ /// The content to be displayed when there is an error.
+ ///
+ [Parameter] public RenderFragment? ErrorContent { get; set; }
+
+ ///
+ /// The maximum number of errors that can be handled. If more errors are received,
+ /// they will be treated as fatal. Calling resets the count.
+ ///
+ [Parameter] public int MaximumErrorCount { get; set; } = 100;
+
+ ///
+ /// Gets the current exception, or null if there is no exception.
+ ///
+ protected Exception? CurrentException { get; private set; }
+
+ ///
+ /// Resets the error boundary to a non-errored state. If the error boundary is not
+ /// already in an errored state, the call has no effect.
+ ///
+ public void Recover()
+ {
+ if (CurrentException is not null)
+ {
+ _errorCount = 0;
+ CurrentException = null;
+ StateHasChanged();
+ }
+ }
+
+ ///
+ /// Invoked by the base class when an error is being handled. Typically, derived classes
+ /// should log the exception from this method.
+ ///
+ /// The being handled.
+ protected abstract Task OnErrorAsync(Exception exception);
+
+ void IErrorBoundary.HandleException(Exception exception)
+ {
+ if (exception is null)
+ {
+ // This would be a framework bug if it happened. It should not be possible.
+ throw new ArgumentNullException(nameof(exception));
+ }
+
+ // If rendering the error content itself causes an error, then re-rendering on error risks creating an
+ // infinite error loop. Unfortunately it's very hard to distinguish whether the error source is "child content"
+ // or "error content", since the exceptions can be received asynchronously, arbitrarily long after we switched
+ // between normal and errored states. Without creating a very intricate coupling between ErrorBoundaryBase and
+ // Renderer internals, the obvious options are either:
+ //
+ // [a] Don't re-render if we're already in an error state. This is problematic because the renderer needs to
+ // discard the error boundary's subtree on every error, in case a custom error boundary fails to do so, and
+ // hence we'd be left with a blank UI if we didn't re-render.
+ // [b] Do re-render each time, and trust the developer not to cause errors from their error content.
+ //
+ // As a middle ground, we try to detect excessive numbers of errors arriving in between recoveries, and treat
+ // an excess as fatal. This also helps to expose the case where a child continues to throw (e.g., on a timer),
+ // which would be very inefficient.
+ if (++_errorCount > MaximumErrorCount)
+ {
+ ExceptionDispatchInfo.Capture(exception).Throw();
+ }
+
+ // Notify the subclass so it can begin any async operation even before we render, because (for example)
+ // we want logs to be written before rendering in case the rendering throws. But there's no reason to
+ // wait for the async operation to complete before we render.
+ var onErrorTask = OnErrorAsync(exception);
+ if (!onErrorTask.IsCompletedSuccessfully)
+ {
+ _ = HandleOnErrorExceptions(onErrorTask);
+ }
+
+ CurrentException = exception;
+ StateHasChanged();
+ }
+
+ private async Task HandleOnErrorExceptions(Task onExceptionTask)
+ {
+ if (onExceptionTask.IsFaulted)
+ {
+ // Synchronous error handling exceptions can simply be fatal to the circuit
+ ExceptionDispatchInfo.Capture(onExceptionTask.Exception!).Throw();
+ }
+ else
+ {
+ // Async exceptions are tricky because there's no natural way to bring them back
+ // onto the sync context within their original circuit. The closest approximation
+ // we have is trying to rethrow via rendering. If, in the future, we add an API for
+ // directly dispatching an exception from ComponentBase, we should use that here.
+ try
+ {
+ await onExceptionTask;
+ }
+ catch (Exception exception)
+ {
+ CurrentException = exception;
+ ChildContent = _ => ExceptionDispatchInfo.Capture(exception).Throw();
+ ErrorContent = _ => _ => ExceptionDispatchInfo.Capture(exception).Throw();
+ StateHasChanged();
+ }
+ }
+ }
+ }
+}
diff --git a/src/Components/Components/src/IErrorBoundary.cs b/src/Components/Components/src/IErrorBoundary.cs
new file mode 100644
index 000000000000..b433dd055737
--- /dev/null
+++ b/src/Components/Components/src/IErrorBoundary.cs
@@ -0,0 +1,23 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.AspNetCore.Components
+{
+ // Purpose of this interface, instead of just using ErrorBoundaryBase directly:
+ //
+ // [1] It keeps clear what is fundamental to an error boundary from the Renderer's perspective.
+ // Anything more specific than this is just a useful pattern inside ErrorBoundaryBase.
+ // [2] It improves linkability. If an application isn't using error boundaries, then all of
+ // ErrorBoundaryBase and its dependencies can be linked out, leaving only this interface.
+ //
+ // If we wanted, we could make this public, but it could lead to common antipatterns such as
+ // routinely marking all components as error boundaries (e.g., in a common base class) in an
+ // attempt to create "On Error Resume Next"-type behaviors.
+
+ internal interface IErrorBoundary
+ {
+ void HandleException(Exception error);
+ }
+}
diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt
index 459cce22c1ad..506b89211a67 100644
--- a/src/Components/Components/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Components/src/PublicAPI.Unshipped.txt
@@ -9,6 +9,16 @@ Microsoft.AspNetCore.Components.ComponentApplicationState.PersistAsJson(
Microsoft.AspNetCore.Components.ComponentApplicationState.PersistState(string! key, byte[]! value) -> void
Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakeAsJson(string! key, out TValue? instance) -> bool
Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakePersistedState(string! key, out byte[]? value) -> bool
+Microsoft.AspNetCore.Components.ErrorBoundaryBase
+Microsoft.AspNetCore.Components.ErrorBoundaryBase.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment?
+Microsoft.AspNetCore.Components.ErrorBoundaryBase.ChildContent.set -> void
+Microsoft.AspNetCore.Components.ErrorBoundaryBase.CurrentException.get -> System.Exception?
+Microsoft.AspNetCore.Components.ErrorBoundaryBase.ErrorBoundaryBase() -> void
+Microsoft.AspNetCore.Components.ErrorBoundaryBase.ErrorContent.get -> Microsoft.AspNetCore.Components.RenderFragment?
+Microsoft.AspNetCore.Components.ErrorBoundaryBase.ErrorContent.set -> void
+Microsoft.AspNetCore.Components.ErrorBoundaryBase.MaximumErrorCount.get -> int
+Microsoft.AspNetCore.Components.ErrorBoundaryBase.MaximumErrorCount.set -> void
+Microsoft.AspNetCore.Components.ErrorBoundaryBase.Recover() -> void
Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime
Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.ComponentApplicationLifetime(Microsoft.Extensions.Logging.ILogger! logger) -> void
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
Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute.CascadingTypeParameterAttribute(string! name) -> void
Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute.Name.get -> string!
Microsoft.AspNetCore.Components.RenderTree.Renderer.GetEventArgsType(ulong eventHandlerId) -> System.Type!
+abstract Microsoft.AspNetCore.Components.ErrorBoundaryBase.OnErrorAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
override Microsoft.AspNetCore.Components.LayoutComponentBase.SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task!
static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary! parameters) -> Microsoft.AspNetCore.Components.ParameterView
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs) -> System.Threading.Tasks.Task!
diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs
index ddcbf714b453..06e1724b7b5c 100644
--- a/src/Components/Components/src/RenderTree/Renderer.cs
+++ b/src/Components/Components/src/RenderTree/Renderer.cs
@@ -7,6 +7,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
+using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.HotReload;
@@ -28,6 +29,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
{
private readonly IServiceProvider _serviceProvider;
private readonly Dictionary _componentStateById = new Dictionary();
+ private readonly Dictionary _componentStateByComponent = new Dictionary();
private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder();
private readonly Dictionary _eventBindings = new Dictionary();
private readonly Dictionary _eventHandlerIdReplacements = new Dictionary();
@@ -290,6 +292,7 @@ private ComponentState AttachAndInitComponent(IComponent component, int parentCo
var componentState = new ComponentState(this, componentId, component, parentComponentState);
Log.InitializingComponent(_logger, componentState, parentComponentState);
_componentStateById.Add(componentId, componentState);
+ _componentStateByComponent.Add(component, componentState);
component.Attach(new RenderHandle(this, componentId));
return componentState;
}
@@ -318,6 +321,16 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie
var callback = GetRequiredEventCallback(eventHandlerId);
Log.HandlingEvent(_logger, eventHandlerId, eventArgs);
+ // Try to match it up with a receiver so that, if the event handler later throws, we can route the error to the
+ // correct error boundary (even if the receiving component got disposed in the meantime).
+ ComponentState? receiverComponentState = null;
+ if (callback.Receiver is IComponent receiverComponent) // The receiver might be null or not an IComponent
+ {
+ // Even if the receiver is an IComponent, it might not be one of ours, or might be disposed already
+ // We can only route errors to error boundaries if the receiver is known and not yet disposed at this stage
+ _componentStateByComponent.TryGetValue(receiverComponent, out receiverComponentState);
+ }
+
if (fieldInfo != null)
{
var latestEquivalentEventHandlerId = FindLatestEventHandlerIdInChain(eventHandlerId);
@@ -335,7 +348,7 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie
}
catch (Exception e)
{
- HandleException(e);
+ HandleExceptionViaErrorBoundary(e, receiverComponentState);
return Task.CompletedTask;
}
finally
@@ -349,7 +362,7 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie
// Task completed synchronously or is still running. We already processed all of the rendering
// work that was queued so let our error handler deal with it.
- var result = GetErrorHandledTask(task);
+ var result = GetErrorHandledTask(task, receiverComponentState);
return result;
}
@@ -389,7 +402,7 @@ internal void InstantiateChildComponentOnFrame(ref RenderTreeFrame frame, int pa
frame.ComponentIdField = newComponentState.ComponentId;
}
- internal void AddToPendingTasks(Task task)
+ internal void AddToPendingTasks(Task task, ComponentState? owningComponentState)
{
switch (task == null ? TaskStatus.RanToCompletion : task.Status)
{
@@ -405,12 +418,13 @@ internal void AddToPendingTasks(Task task)
// an 'async' state machine (the ones generated using async/await) where even
// the synchronous exceptions will get captured and converted into a faulted
// task.
- HandleException(task.Exception.GetBaseException());
+ var baseException = task.Exception.GetBaseException();
+ HandleExceptionViaErrorBoundary(baseException, owningComponentState);
break;
default:
// It's important to evaluate the following even if we're not going to use
// handledErrorTask below, because it has the side-effect of calling HandleException.
- var handledErrorTask = GetErrorHandledTask(task);
+ var handledErrorTask = GetErrorHandledTask(task, owningComponentState);
// The pendingTasks collection is only used during prerendering to track quiescence,
// so will be null at other times.
@@ -691,7 +705,7 @@ private void NotifyRenderCompleted(ComponentState state, ref List batch)
}
else if (task.Status == TaskStatus.Faulted)
{
- HandleException(task.Exception);
+ HandleExceptionViaErrorBoundary(task.Exception, state);
return;
}
}
@@ -699,14 +713,19 @@ private void NotifyRenderCompleted(ComponentState state, ref List batch)
// The Task is incomplete.
// Queue up the task and we can inspect it later.
batch = batch ?? new List();
- batch.Add(GetErrorHandledTask(task));
+ batch.Add(GetErrorHandledTask(task, state));
}
private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry)
{
var componentState = renderQueueEntry.ComponentState;
Log.RenderingComponent(_logger, componentState);
- componentState.RenderIntoBatch(_batchBuilder, renderQueueEntry.RenderFragment);
+ componentState.RenderIntoBatch(_batchBuilder, renderQueueEntry.RenderFragment, out var renderFragmentException);
+ if (renderFragmentException != null)
+ {
+ // If this returns, the error was handled by an error boundary. Otherwise it throws.
+ HandleExceptionViaErrorBoundary(renderFragmentException, componentState);
+ }
List exceptions = null;
@@ -737,7 +756,8 @@ private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry)
}
else
{
- AddToPendingTasks(GetHandledAsynchronousDisposalErrorsTask(result));
+ // We set owningComponentState to null because we don't want exceptions during disposal to be recoverable
+ AddToPendingTasks(GetHandledAsynchronousDisposalErrorsTask(result), owningComponentState: null);
async Task GetHandledAsynchronousDisposalErrorsTask(Task result)
{
@@ -754,6 +774,7 @@ async Task GetHandledAsynchronousDisposalErrorsTask(Task result)
}
_componentStateById.Remove(disposeComponentId);
+ _componentStateByComponent.Remove(disposeComponentState.Component);
_batchBuilder.DisposedComponentIds.Append(disposeComponentId);
}
@@ -815,7 +836,7 @@ async Task ContinueAfterTask(ArrayRange eventHandlerIds, Task afterTaskIg
}
}
- private async Task GetErrorHandledTask(Task taskToHandle)
+ private async Task GetErrorHandledTask(Task taskToHandle, ComponentState? owningComponentState)
{
try
{
@@ -823,10 +844,10 @@ private async Task GetErrorHandledTask(Task taskToHandle)
}
catch (Exception ex)
{
+ // Ignore errors due to task cancellations.
if (!taskToHandle.IsCanceled)
{
- // Ignore errors due to task cancellations.
- HandleException(ex);
+ HandleExceptionViaErrorBoundary(ex, owningComponentState);
}
}
}
@@ -843,6 +864,47 @@ private void UpdateRenderTreeToMatchClientState(ulong eventHandlerId, EventField
}
}
+ ///
+ /// If the exception can be routed to an error boundary around , do so.
+ /// Otherwise handle it as fatal.
+ ///
+ private void HandleExceptionViaErrorBoundary(Exception error, ComponentState? errorSourceOrNull)
+ {
+ // We only get here in specific situations. Currently, all of them are when we're
+ // already on the sync context (and if not, we have a bug we want to know about).
+ Dispatcher.AssertAccess();
+
+ // Find the closest error boundary, if any
+ var candidate = errorSourceOrNull;
+ while (candidate is not null)
+ {
+ if (candidate.Component is IErrorBoundary errorBoundary)
+ {
+ // Don't just trust the error boundary to dispose its subtree - force it to do so by
+ // making it render an empty fragment. Ensures that failed components don't continue to
+ // operate, which would be a whole new kind of edge case to support forever.
+ AddToRenderQueue(candidate.ComponentId, builder => { });
+
+ try
+ {
+ errorBoundary.HandleException(error);
+ }
+ catch (Exception errorBoundaryException)
+ {
+ // If *notifying* about an exception fails, it's OK for that to be fatal
+ HandleException(errorBoundaryException);
+ }
+
+ return; // Handled successfully
+ }
+
+ candidate = candidate.ParentComponentState;
+ }
+
+ // It's unhandled, so treat as fatal
+ HandleException(error);
+ }
+
///
/// Releases all resources currently used by this instance.
///
@@ -896,6 +958,7 @@ protected virtual void Dispose(bool disposing)
}
_componentStateById.Clear(); // So we know they were all disposed
+ _componentStateByComponent.Clear();
_batchBuilder.Dispose();
NotifyExceptions(exceptions);
diff --git a/src/Components/Components/src/Rendering/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs
index de620ccf8ffe..f5692f83e76b 100644
--- a/src/Components/Components/src/Rendering/ComponentState.cs
+++ b/src/Components/Components/src/Rendering/ComponentState.cs
@@ -20,7 +20,7 @@ internal class ComponentState : IDisposable
private readonly IReadOnlyList _cascadingParameters;
private readonly bool _hasCascadingParameters;
private readonly bool _hasAnyCascadingParameterSubscriptions;
- private RenderTreeBuilder _renderTreeBuilderPrevious;
+ private RenderTreeBuilder _nextRenderTree;
private ArrayBuilder? _latestDirectParametersSnapshot; // Lazily instantiated
private bool _componentWasDisposed;
@@ -39,7 +39,7 @@ public ComponentState(Renderer renderer, int componentId, IComponent component,
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_cascadingParameters = CascadingParameterState.FindCascadingParameters(this);
CurrentRenderTree = new RenderTreeBuilder();
- _renderTreeBuilderPrevious = new RenderTreeBuilder();
+ _nextRenderTree = new RenderTreeBuilder();
if (_cascadingParameters.Count != 0)
{
@@ -54,8 +54,10 @@ public ComponentState(Renderer renderer, int componentId, IComponent component,
public ComponentState ParentComponentState { get; }
public RenderTreeBuilder CurrentRenderTree { get; private set; }
- public void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment renderFragment)
+ public void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment renderFragment, out Exception? renderFragmentException)
{
+ renderFragmentException = null;
+
// A component might be in the render queue already before getting disposed by an
// earlier entry in the render queue. In that case, rendering is a no-op.
if (_componentWasDisposed)
@@ -63,19 +65,32 @@ public void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment rend
return;
}
- // Swap the old and new tree builders
- (CurrentRenderTree, _renderTreeBuilderPrevious) = (_renderTreeBuilderPrevious, CurrentRenderTree);
+ _nextRenderTree.Clear();
- CurrentRenderTree.Clear();
- renderFragment(CurrentRenderTree);
+ try
+ {
+ renderFragment(_nextRenderTree);
+ }
+ catch (Exception ex)
+ {
+ // If an exception occurs in the render fragment delegate, we won't process the diff in any way, so child components,
+ // event handlers, etc., will all be left untouched as if this component didn't re-render at all. The Renderer will
+ // then forcibly clear the descendant subtree by rendering an empty fragment for this component.
+ renderFragmentException = ex;
+ return;
+ }
- CurrentRenderTree.AssertTreeIsValid(Component);
+ // We don't want to make errors from this be recoverable, because there's no legitimate reason for them to happen
+ _nextRenderTree.AssertTreeIsValid(Component);
+
+ // Swap the old and new tree builders
+ (CurrentRenderTree, _nextRenderTree) = (_nextRenderTree, CurrentRenderTree);
var diff = RenderTreeDiffBuilder.ComputeDiff(
_renderer,
batchBuilder,
ComponentId,
- _renderTreeBuilderPrevious.GetFrames(),
+ _nextRenderTree.GetFrames(),
CurrentRenderTree.GetFrames());
batchBuilder.UpdatedComponentDiffs.Append(diff);
batchBuilder.InvalidateParameterViews();
@@ -164,7 +179,7 @@ public void SetDirectParameters(ParameterView parameters)
parameters = parameters.WithCascadingParameters(_cascadingParameters);
}
- _renderer.AddToPendingTasks(Component.SetParametersAsync(parameters));
+ SupplyCombinedParameters(parameters);
}
public void NotifyCascadingValueChanged(in ParameterViewLifetime lifetime)
@@ -173,8 +188,26 @@ public void NotifyCascadingValueChanged(in ParameterViewLifetime lifetime)
? new ParameterView(lifetime, _latestDirectParametersSnapshot.Buffer, 0)
: ParameterView.Empty;
var allParams = directParams.WithCascadingParameters(_cascadingParameters!);
- var task = Component.SetParametersAsync(allParams);
- _renderer.AddToPendingTasks(task);
+ SupplyCombinedParameters(allParams);
+ }
+
+ // This should not be called from anywhere except SetDirectParameters or NotifyCascadingValueChanged.
+ // Those two methods know how to correctly combine both cascading and non-cascading parameters to supply
+ // a consistent set to the recipient.
+ private void SupplyCombinedParameters(ParameterView directAndCascadingParameters)
+ {
+ // Normalise sync and async exceptions into a Task
+ Task setParametersAsyncTask;
+ try
+ {
+ setParametersAsyncTask = Component.SetParametersAsync(directAndCascadingParameters);
+ }
+ catch (Exception ex)
+ {
+ setParametersAsyncTask = Task.FromException(ex);
+ }
+
+ _renderer.AddToPendingTasks(setParametersAsyncTask, owningComponentState: this);
}
private bool AddCascadingParameterSubscriptions()
@@ -220,7 +253,7 @@ public void Dispose()
private void DisposeBuffers()
{
- ((IDisposable)_renderTreeBuilderPrevious).Dispose();
+ ((IDisposable)_nextRenderTree).Dispose();
((IDisposable)CurrentRenderTree).Dispose();
_latestDirectParametersSnapshot?.Dispose();
}
diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs
index 3c12040f9a0d..85185a710e84 100644
--- a/src/Components/Components/test/RendererTest.cs
+++ b/src/Components/Components/test/RendererTest.cs
@@ -4170,6 +4170,247 @@ public async Task ThrowsIfComponentProducesInvalidRenderTree()
Assert.StartsWith($"Render output is invalid for component of type '{typeof(TestComponent).FullName}'. A frame of type 'Element' was left unclosed.", ex.Message);
}
+ [Fact]
+ public void RenderingExceptionsCanBeHandledByClosestErrorBoundary()
+ {
+ // Arrange
+ var renderer = new TestRenderer();
+ var exception = new InvalidTimeZoneException("Error during render");
+ var rootComponentId = renderer.AssignRootComponentId(new TestComponent(builder =>
+ {
+ TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder =>
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringRender), exception);
+ builder.CloseComponent();
+ });
+ }));
+
+ // Act
+ renderer.RenderRootComponent(rootComponentId);
+
+ // Assert
+ var batch = renderer.Batches.Single();
+ var errorThrowingComponentId = batch.GetComponentFrames().Single().ComponentId;
+ var componentFrames = batch.GetComponentFrames();
+ Assert.Collection(componentFrames.Select(f => (TestErrorBoundary)f.Component),
+ component => Assert.Null(component.ReceivedException),
+ component => Assert.Same(exception, component.ReceivedException));
+
+ // The failed subtree is disposed
+ Assert.Equal(errorThrowingComponentId, batch.DisposedComponentIDs.Single());
+ }
+
+ [Fact]
+ public void SetParametersAsyncExceptionsCanBeHandledByClosestErrorBoundary_Sync()
+ {
+ // Arrange
+ var renderer = new TestRenderer();
+ Exception exception = null;
+ var rootComponent = new TestComponent(builder =>
+ {
+ TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder =>
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringParameterSettingSync), exception);
+ builder.CloseComponent();
+ });
+ });
+ var rootComponentId = renderer.AssignRootComponentId(rootComponent);
+ renderer.RenderRootComponent(rootComponentId);
+ var errorBoundaries = renderer.Batches.Single().GetComponentFrames()
+ .Select(f => (TestErrorBoundary)f.Component);
+ var errorThrowingComponentId = renderer.Batches.Single()
+ .GetComponentFrames().Single().ComponentId;
+
+ // Act
+ exception = new InvalidTimeZoneException("Error during SetParametersAsync");
+ rootComponent.TriggerRender();
+
+ // Assert
+ Assert.Equal(2, renderer.Batches.Count);
+ Assert.Collection(errorBoundaries,
+ component => Assert.Null(component.ReceivedException),
+ component => Assert.Same(exception, component.ReceivedException));
+
+ // The failed subtree is disposed
+ Assert.Equal(errorThrowingComponentId, renderer.Batches[1].DisposedComponentIDs.Single());
+ }
+
+ [Fact]
+ public async Task SetParametersAsyncExceptionsCanBeHandledByClosestErrorBoundary_Async()
+ {
+ // Arrange
+ var renderer = new TestRenderer();
+ var exception = new InvalidTimeZoneException("Error during SetParametersAsync");
+ TaskCompletionSource exceptionTcs = null;
+ var rootComponent = new TestComponent(builder =>
+ {
+ TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder =>
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringParameterSettingAsync), exceptionTcs?.Task);
+ builder.CloseComponent();
+ });
+ });
+ var rootComponentId = renderer.AssignRootComponentId(rootComponent);
+ renderer.RenderRootComponent(rootComponentId);
+ var errorBoundaries = renderer.Batches.Single().GetComponentFrames()
+ .Select(f => (TestErrorBoundary)f.Component).ToArray();
+ var errorThrowingComponentId = renderer.Batches.Single()
+ .GetComponentFrames().Single().ComponentId;
+
+ // Act/Assert 1: No synchronous errors
+ exceptionTcs = new TaskCompletionSource();
+ rootComponent.TriggerRender();
+ Assert.Equal(2, renderer.Batches.Count);
+
+ // Act/Assert 2: Asynchronous error
+ exceptionTcs.SetException(exception);
+ await errorBoundaries[1].ReceivedErrorTask;
+ Assert.Equal(3, renderer.Batches.Count);
+ Assert.Collection(errorBoundaries,
+ component => Assert.Null(component.ReceivedException),
+ component => Assert.Same(exception, component.ReceivedException));
+
+ // The failed subtree is disposed
+ Assert.Equal(errorThrowingComponentId, renderer.Batches[2].DisposedComponentIDs.Single());
+ }
+
+ [Fact]
+ public void EventDispatchExceptionsCanBeHandledByClosestErrorBoundary_Sync()
+ {
+ // Arrange
+ var renderer = new TestRenderer();
+ var exception = new InvalidTimeZoneException("Error during event");
+ var rootComponentId = renderer.AssignRootComponentId(new TestComponent(builder =>
+ {
+ TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder =>
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringEventSync), exception);
+ builder.CloseComponent();
+ });
+ }));
+ renderer.RenderRootComponent(rootComponentId);
+ var errorBoundaries = renderer.Batches.Single().GetComponentFrames()
+ .Select(f => (TestErrorBoundary)f.Component);
+ var errorThrowingComponentId = renderer.Batches.Single()
+ .GetComponentFrames().Single().ComponentId;
+ var eventHandlerId = renderer.Batches.Single().ReferenceFrames
+ .Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "onmakeerror")
+ .AttributeEventHandlerId;
+
+ // Act
+ var task = renderer.DispatchEventAsync(eventHandlerId, new EventArgs());
+
+ // Assert
+ Assert.True(task.IsCompletedSuccessfully);
+ Assert.Equal(2, renderer.Batches.Count);
+ Assert.Collection(errorBoundaries,
+ component => Assert.Null(component.ReceivedException),
+ component => Assert.Same(exception, component.ReceivedException));
+
+ // The failed subtree is disposed
+ Assert.Equal(errorThrowingComponentId, renderer.Batches[1].DisposedComponentIDs.Single());
+ }
+
+ [Fact]
+ public async Task EventDispatchExceptionsCanBeHandledByClosestErrorBoundary_Async()
+ {
+ // Arrange
+ var renderer = new TestRenderer();
+ var exception = new InvalidTimeZoneException("Error during event");
+ var exceptionTcs = new TaskCompletionSource();
+ var rootComponentId = renderer.AssignRootComponentId(new TestComponent(builder =>
+ {
+ TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder =>
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringEventAsync), exceptionTcs.Task);
+ builder.CloseComponent();
+ });
+ }));
+ renderer.RenderRootComponent(rootComponentId);
+ var errorBoundaries = renderer.Batches.Single().GetComponentFrames()
+ .Select(f => (TestErrorBoundary)f.Component);
+ var errorThrowingComponentId = renderer.Batches.Single()
+ .GetComponentFrames().Single().ComponentId;
+ var eventHandlerId = renderer.Batches.Single().ReferenceFrames
+ .Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "onmakeerror")
+ .AttributeEventHandlerId;
+
+ // Act/Assert 1: No error synchronously
+ var dispatchEventTask = renderer.DispatchEventAsync(eventHandlerId, new EventArgs());
+ Assert.Single(renderer.Batches);
+ Assert.Collection(errorBoundaries,
+ component => Assert.Null(component.ReceivedException),
+ component => Assert.Null(component.ReceivedException));
+
+ // Act/Assert 2: Error is handled asynchronously
+ exceptionTcs.SetException(exception);
+ await dispatchEventTask;
+ Assert.Equal(2, renderer.Batches.Count);
+ Assert.Collection(errorBoundaries,
+ component => Assert.Null(component.ReceivedException),
+ component => Assert.Same(exception, component.ReceivedException));
+
+ // The failed subtree is disposed
+ Assert.Equal(errorThrowingComponentId, renderer.Batches[1].DisposedComponentIDs.Single());
+ }
+
+ [Fact]
+ public async Task EventDispatchExceptionsCanBeHandledByClosestErrorBoundary_AfterDisposal()
+ {
+ // Arrange
+ var renderer = new TestRenderer();
+ var disposeChildren = false;
+ var exception = new InvalidTimeZoneException("Error during event");
+ var exceptionTcs = new TaskCompletionSource();
+ var rootComponent = new TestComponent(builder =>
+ {
+ if (!disposeChildren)
+ {
+ TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder =>
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringEventAsync), exceptionTcs.Task);
+ builder.CloseComponent();
+ });
+ }
+ });
+ var rootComponentId = renderer.AssignRootComponentId(rootComponent);
+ renderer.RenderRootComponent(rootComponentId);
+ var errorBoundaries = renderer.Batches.Single().GetComponentFrames()
+ .Select(f => (TestErrorBoundary)f.Component);
+ var errorThrowingComponentId = renderer.Batches.Single()
+ .GetComponentFrames().Single().ComponentId;
+ var eventHandlerId = renderer.Batches.Single().ReferenceFrames
+ .Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "onmakeerror")
+ .AttributeEventHandlerId;
+
+ // Act/Assert 1: No error synchronously
+ var dispatchEventTask = renderer.DispatchEventAsync(eventHandlerId, new EventArgs());
+ Assert.Single(renderer.Batches);
+ Assert.Collection(errorBoundaries,
+ component => Assert.Null(component.ReceivedException),
+ component => Assert.Null(component.ReceivedException));
+
+ // Act 2: Before the async error occurs, dispose the hierarchy containing the error boundary and erroring component
+ disposeChildren = true;
+ rootComponent.TriggerRender();
+ Assert.Equal(2, renderer.Batches.Count);
+ Assert.Contains(errorThrowingComponentId, renderer.Batches.Last().DisposedComponentIDs);
+
+ // Assert 2: Error is still handled
+ exceptionTcs.SetException(exception);
+ await dispatchEventTask;
+ Assert.Equal(2, renderer.Batches.Count); // Didn't re-render as the error boundary was already gone
+ Assert.Collection(errorBoundaries,
+ component => Assert.Null(component.ReceivedException),
+ component => Assert.Same(exception, component.ReceivedException));
+ }
+
private class TestComponentActivator : IComponentActivator where TResult : IComponent, new()
{
public List RequestedComponentTypes { get; } = new List();
@@ -4401,10 +4642,9 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.OpenComponent(1);
if (ChildParameters != null)
{
- var sequence = 2;
foreach (var kvp in ChildParameters)
{
- builder.AddAttribute(sequence++, kvp.Key, kvp.Value);
+ builder.AddAttribute(2, kvp.Key, kvp.Value);
}
}
builder.CloseComponent();
@@ -4620,9 +4860,8 @@ public async Task SetParametersAsync(ParameterView parameters)
// Cheap closure
void CreateFragment(RenderTreeBuilder builder)
{
- var s = 0;
- builder.OpenElement(s++, "p");
- builder.AddContent(s++, n);
+ builder.OpenElement(0, "p");
+ builder.AddContent(1, n);
builder.CloseElement();
}
}
@@ -4701,16 +4940,15 @@ private Func CreateRenderFactory(int[] chi
return component => builder =>
{
- var s = 0;
- builder.OpenElement(s++, "div");
- builder.AddContent(s++, $"Id: {component.TestId} BuildRenderTree, {Guid.NewGuid()}");
+ builder.OpenElement(0, "div");
+ builder.AddContent(1, $"Id: {component.TestId} BuildRenderTree, {Guid.NewGuid()}");
foreach (var child in childrenToRender)
{
- builder.OpenComponent(s++);
- builder.AddAttribute(s++, eventActionsName, component.EventActions);
- builder.AddAttribute(s++, whatToRenderName, component.WhatToRender);
- builder.AddAttribute(s++, testIdName, child);
- builder.AddAttribute(s++, logName, component.Log);
+ builder.OpenComponent(2);
+ builder.AddAttribute(3, eventActionsName, component.EventActions);
+ builder.AddAttribute(4, whatToRenderName, component.WhatToRender);
+ builder.AddAttribute(5, testIdName, child);
+ builder.AddAttribute(6, logName, component.Log);
builder.CloseComponent();
}
@@ -4970,5 +5208,88 @@ public Task SetParametersAsync(ParameterView parameters)
return new TaskCompletionSource