diff --git a/src/Components/Components/src/ComponentBase.cs b/src/Components/Components/src/ComponentBase.cs index 5de04ae8d70b..eefe8eb74655 100644 --- a/src/Components/Components/src/ComponentBase.cs +++ b/src/Components/Components/src/ComponentBase.cs @@ -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 { @@ -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) : diff --git a/src/Components/Components/test/ComponentBaseTest.cs b/src/Components/Components/test/ComponentBaseTest.cs index b8a928a0ec41..c525dfccc1ac 100644 --- a/src/Components/Components/test/ComponentBaseTest.cs +++ b/src/Components/Components/test/ComponentBaseTest.cs @@ -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(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundary.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(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(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundary.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(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(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(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(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(renderer.HandledExceptions[0]); + Assert.NotNull(testComponentErrorBuildRenderTree); + Assert.Equal(0, testComponentErrorBuildRenderTree.StateHasChangedCalled); + } + [Fact] public async Task DoesNotRenderAfterOnParametersSetAsyncTaskIsCanceled() { @@ -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() @@ -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; + } + } } diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticatorCoreTests.cs b/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticatorCoreTests.cs index 694e3aea29f4..edf7b0e79160 100644 --- a/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticatorCoreTests.cs +++ b/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticatorCoreTests.cs @@ -34,7 +34,7 @@ public async Task AuthenticationManager_Throws_ForInvalidAction() }); // Act & assert - await Assert.ThrowsAsync(() => remoteAuthenticator.SetParametersAsync(parameters)); + await Assert.ThrowsAsync(() => remoteAuthenticator.SetParametersAsync(parameters)); } [Fact] diff --git a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs index 50e6fd06deef..80402e38bf54 100644 --- a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs +++ b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs @@ -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() { diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor index bcc4aec249e0..6b1916019d06 100644 --- a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor @@ -70,6 +70,22 @@ +
+

Two errors in child

+
+ @if (twoErrorsInChild) { + + + + + +

@context.Message

+
+
+ } + +
+

Errors after disposal

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.

@@ -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"); diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/MultipleErrorsChild.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/MultipleErrorsChild.razor new file mode 100644 index 000000000000..f8f4c768f4ba --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/MultipleErrorsChild.razor @@ -0,0 +1,12 @@ +

MultipleErrorsChild

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