Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/Components/Components/src/ComponentBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,10 @@ private async Task RunInitAndSetParametersAsync()
// to defer calling StateHasChanged up until the first bit of async code happens or until
// the end. Additionally, we want to avoid calling StateHasChanged if no
// async work is to be performed.
StateHasChanged();
if (task.Status != TaskStatus.Faulted)
{
StateHasChanged();
}

try
{
Expand Down Expand Up @@ -319,7 +322,10 @@ private Task CallOnParametersSetAsync()

// We always call StateHasChanged here as we want to trigger a rerender after OnParametersSet and
// the synchronous part of OnParametersSetAsync has run.
StateHasChanged();
if (task.Status != TaskStatus.Faulted)
{
StateHasChanged();
}

return shouldAwaitTask ?
CallStateHasChangedOnAsyncCompletion(task) :
Expand Down
196 changes: 193 additions & 3 deletions src/Components/Components/test/ComponentBaseTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,126 @@ public async Task DoesNotRenderAfterOnInitAsyncTaskIsCancelledUsingCancellationT
Assert.NotEmpty(renderer.Batches);
}

[Fact]
public async Task ErrorBoundaryHandlesOnInitializedAsyncReturnFaultedTask()
{
// Arrange
var renderer = new TestRenderer();
TestErrorBoundary capturedBoundary = null;

// Create root component that wraps the TestComponentErrorBuildRenderTree in an TestErrorBoundary
var rootComponent = new TestComponent();
rootComponent.ChildContent = builder =>
{
builder.OpenComponent<TestErrorBoundary>(0);
builder.AddComponentParameter(1, nameof(TestErrorBoundary.ChildContent), (RenderFragment)(childBuilder =>
{
childBuilder.OpenComponent<TestComponentErrorBuildRenderTree>(0);
childBuilder.AddComponentParameter(1, nameof(TestComponentErrorBuildRenderTree.FaultedTaskOnInitializedAsync), true);
childBuilder.CloseComponent();
}));
builder.AddComponentReferenceCapture(2, inst => capturedBoundary = (TestErrorBoundary)inst);
builder.CloseComponent();
};

// Act
var rootComponentId = renderer.AssignRootComponentId(rootComponent);
await renderer.RenderRootComponentAsync(rootComponentId);

// Assert
Assert.NotNull(capturedBoundary);
Assert.NotNull(capturedBoundary!.ReceivedException);
Assert.Equal(typeof(InvalidTimeZoneException), capturedBoundary!.ReceivedException.GetType());
}

[Fact]
public async Task ErrorBoundaryHandlesCallOnParametersSetAsyncReturnFaultedTask()
{
// Arrange
var renderer = new TestRenderer();
TestErrorBoundary capturedBoundary = null;

// Create root component that wraps the TestComponentErrorBuildRenderTree in an TestErrorBoundary
var rootComponent = new TestComponent();
rootComponent.ChildContent = builder =>
{
builder.OpenComponent<TestErrorBoundary>(0);
builder.AddComponentParameter(1, nameof(TestErrorBoundary.ChildContent), (RenderFragment)(childBuilder =>
{
childBuilder.OpenComponent<TestComponentErrorBuildRenderTree>(0);
childBuilder.AddComponentParameter(1, nameof(TestComponentErrorBuildRenderTree.FaultedTaskOnParametersSetAsync), true);
childBuilder.CloseComponent();
}));
builder.AddComponentReferenceCapture(2, inst => capturedBoundary = (TestErrorBoundary)inst);
builder.CloseComponent();
};

// Act
var rootComponentId = renderer.AssignRootComponentId(rootComponent);
await renderer.RenderRootComponentAsync(rootComponentId);

// Assert
Assert.NotNull(capturedBoundary);
Assert.NotNull(capturedBoundary!.ReceivedException);
Assert.Equal(typeof(InvalidTimeZoneException), capturedBoundary!.ReceivedException.GetType());
}

[Fact]
public async Task ComponentBaseDoesntRenderWhenOnInitializedAsyncFaultedTask()
{
// Arrange
var renderer = new TestRenderer();
renderer.ShouldHandleExceptions = true;
TestComponentErrorBuildRenderTree testComponentErrorBuildRenderTree = null;

// Create root component that wraps the TestComponentErrorBuildRenderTree in an TestErrorBoundary
var rootComponent = new TestComponent();
rootComponent.ChildContent = builder =>
{
builder.OpenComponent<TestComponentErrorBuildRenderTree>(0);
builder.AddComponentParameter(1, nameof(TestComponentErrorBuildRenderTree.FaultedTaskOnInitializedAsync), true);
builder.AddComponentReferenceCapture(2, inst => testComponentErrorBuildRenderTree = (TestComponentErrorBuildRenderTree)inst);
builder.CloseComponent();
};

// Act
var rootComponentId = renderer.AssignRootComponentId(rootComponent);
await renderer.RenderRootComponentAsync(rootComponentId);

// Assert
Assert.IsType<InvalidTimeZoneException>(renderer.HandledExceptions[0]);
Assert.NotNull(testComponentErrorBuildRenderTree);
Assert.Equal(0, testComponentErrorBuildRenderTree.StateHasChangedCalled);
}

[Fact]
public async Task ComponentBaseDoesntRenderWhenOnSetParametersSetAsyncFaultedTask()
{
// Arrange
var renderer = new TestRenderer();
renderer.ShouldHandleExceptions = true;
TestComponentErrorBuildRenderTree testComponentErrorBuildRenderTree = null;

// Create root component that wraps the TestComponentErrorBuildRenderTree in an TestErrorBoundary
var rootComponent = new TestComponent();
rootComponent.ChildContent = builder =>
{
builder.OpenComponent<TestComponentErrorBuildRenderTree>(0);
builder.AddComponentParameter(1, nameof(TestComponentErrorBuildRenderTree.FaultedTaskOnParametersSetAsync), true);
builder.AddComponentReferenceCapture(2, inst => testComponentErrorBuildRenderTree = (TestComponentErrorBuildRenderTree)inst);
builder.CloseComponent();
};

// Act
var rootComponentId = renderer.AssignRootComponentId(rootComponent);
await renderer.RenderRootComponentAsync(rootComponentId);

// Assert
Assert.IsType<InvalidTimeZoneException>(renderer.HandledExceptions[0]);
Assert.NotNull(testComponentErrorBuildRenderTree);
Assert.Equal(0, testComponentErrorBuildRenderTree.StateHasChangedCalled);
}

[Fact]
public async Task DoesNotRenderAfterOnParametersSetAsyncTaskIsCanceled()
{
Expand Down Expand Up @@ -491,11 +611,20 @@ private class TestComponent : ComponentBase

public int Counter { get; set; }

public RenderFragment ChildContent { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "p");
builder.AddContent(1, Counter);
builder.CloseElement();
if (ChildContent != null)
{
builder.AddContent(0, ChildContent);
}
else
{
builder.OpenElement(0, "p");
builder.AddContent(1, Counter);
builder.CloseElement();
}
}

protected override void OnInitialized()
Expand Down Expand Up @@ -570,4 +699,65 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
}
}
}

private class TestErrorBoundary : ErrorBoundaryBase
{
public Exception ReceivedException => CurrentException;

protected override Task OnErrorAsync(Exception exception)
{
return Task.CompletedTask;
}

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (CurrentException == null)
{
builder.AddContent(0, ChildContent);
}
else
{
builder.OpenElement(2, "div");
builder.AddAttribute(3, "class", "blazor-error-boundary");
builder.CloseElement();
}
}
}

private class TestComponentErrorBuildRenderTree : ComponentBase
{
[Parameter] public bool FaultedTaskOnInitializedAsync { get; set; } = false;
[Parameter] public bool FaultedTaskOnParametersSetAsync { get; set; } = false;

public int StateHasChangedCalled { get; set; } = 0;

protected new void StateHasChanged()
{
StateHasChangedCalled++;
base.StateHasChanged();
}

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
throw new InvalidOperationException("Error in BuildRenderTree");
}

protected override Task OnInitializedAsync()
{
if (FaultedTaskOnInitializedAsync)
{
return Task.FromException(new InvalidTimeZoneException());
}
return Task.CompletedTask;
}

protected override Task OnParametersSetAsync()
{
if (FaultedTaskOnParametersSetAsync)
{
return Task.FromException(new InvalidTimeZoneException());
}
return Task.CompletedTask;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task AuthenticationManager_Throws_ForInvalidAction()
});

// Act & assert
await Assert.ThrowsAsync<InvalidOperationException>(() => remoteAuthenticator.SetParametersAsync(parameters));
await Assert.ThrowsAsync<NullReferenceException>(() => remoteAuthenticator.SetParametersAsync(parameters));
}

[Fact]
Expand Down
11 changes: 11 additions & 0 deletions src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ public void CanHandleErrorsAfterDisposingComponent()
AssertGlobalErrorState(false);
}

[Fact]
public void CanHandleErrorsAfterDisposingErrorBoundaryComponent()
{
var container = Browser.Exists(By.Id("multiple-errors-at-once-test"));
container.FindElement(By.ClassName("throw-multiple-errors")).Click();
// The error boundary is still there, so we see the error message
Browser.Collection(() => container.FindElements(By.ClassName("error-message")),
elem => Assert.Equal("OnInitializedAsyncError", elem.Text));
AssertGlobalErrorState(false);
}

[Fact]
public async Task CanHandleErrorsAfterDisposingErrorBoundary()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@
<button class="throw-in-errorcontent" @onclick="@(() => throwInErrorContent = true)">Throw in error content (causing infinite error loop)</button>
</div>

<hr/>
<h2>Two errors in child</h2>
<div id="multiple-errors-at-once-test">
@if (twoErrorsInChild) {
<ErrorBoundary>
<ChildContent>
<MultipleErrorsChild />
</ChildContent>
<ErrorContent>
<p class="error-message">@context.Message</p>
</ErrorContent>
</ErrorBoundary>
}
<button class="throw-multiple-errors" @onclick="@(() => twoErrorsInChild = true)">Throw multiple errors in child</button>
</div>

<hr />
<h2>Errors after disposal</h2>
<p>Long-running tasks could fail after the component has been removed from the tree. We still want these failures to be captured by the error boundary they were inside when the task began, even if that error boundary itself has also since been disposed. Otherwise, error handling would behave differently based on whether the user has navigated away while processing was in flight, which would be very unexpected and hard to handle.</p>
Expand Down Expand Up @@ -122,6 +138,7 @@
private bool disposalTestRemoveErrorBoundary;
private bool disposalTestBeginDelayedError;
private bool multipleChildrenBeginDelayedError;
private bool twoErrorsInChild;

void EventHandlerErrorSync()
=> throw new InvalidTimeZoneException("Synchronous error from event handler");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<h3>MultipleErrorsChild</h3>

@{
throw new Exception("BuildRenderTreeError");
}

@code {
protected override Task OnInitializedAsync()
{
return Task.FromException(new Exception("OnInitializedAsyncError"));
}
}
Loading