From 4aae250b0f9a1d7bd4499b5bd86478e03ca3874a Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 13 Aug 2025 13:57:51 +0200 Subject: [PATCH 1/4] Fixed the ComponentBase faulted task bug --- .../Components/src/ComponentBase.cs | 5 +- .../Components/test/ComponentBaseTest.cs | 83 ++++++++++++++++++- .../test/E2ETest/Tests/ErrorBoundaryTest.cs | 11 +++ .../ErrorBoundaryCases.razor | 17 ++++ .../MultipleErrorsChild.razor | 12 +++ 5 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/MultipleErrorsChild.razor diff --git a/src/Components/Components/src/ComponentBase.cs b/src/Components/Components/src/ComponentBase.cs index 5de04ae8d70b..2e0dc645d7b2 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 { diff --git a/src/Components/Components/test/ComponentBaseTest.cs b/src/Components/Components/test/ComponentBaseTest.cs index b8a928a0ec41..a599a006cf25 100644 --- a/src/Components/Components/test/ComponentBaseTest.cs +++ b/src/Components/Components/test/ComponentBaseTest.cs @@ -372,6 +372,37 @@ 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.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 DoesNotRenderAfterOnParametersSetAsyncTaskIsCanceled() { @@ -491,11 +522,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 +610,41 @@ 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 + { + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + throw new InvalidOperationException("Error in BuildRenderTree"); + } + + protected override Task OnInitializedAsync() + { + return Task.FromException(new InvalidTimeZoneException()); + } + } } diff --git a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs index 50e6fd06deef..99981217df0e 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-miltiple-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..802f33f01ec6 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")); + } +} From f7501e3447d59df7c27d80e2d0bff8a7780ec1aa Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Thu, 14 Aug 2025 12:45:57 +0200 Subject: [PATCH 2/4] Fix typo --- src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs | 2 +- .../BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs index 99981217df0e..80402e38bf54 100644 --- a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs +++ b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs @@ -130,7 +130,7 @@ public void CanHandleErrorsAfterDisposingComponent() public void CanHandleErrorsAfterDisposingErrorBoundaryComponent() { var container = Browser.Exists(By.Id("multiple-errors-at-once-test")); - container.FindElement(By.ClassName("throw-miltiple-errors")).Click(); + 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)); diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor index 802f33f01ec6..6b1916019d06 100644 --- a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor @@ -83,7 +83,7 @@ } - +
From a7a74c472eaec8a2cc0bc8410371ecd23d4d5aff Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Thu, 14 Aug 2025 15:42:51 +0200 Subject: [PATCH 3/4] Added fixing CallOnParametersSetAsync --- .../Components/src/ComponentBase.cs | 5 +- .../Components/test/ComponentBaseTest.cs | 115 +++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/Components/Components/src/ComponentBase.cs b/src/Components/Components/src/ComponentBase.cs index 2e0dc645d7b2..eefe8eb74655 100644 --- a/src/Components/Components/src/ComponentBase.cs +++ b/src/Components/Components/src/ComponentBase.cs @@ -322,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 a599a006cf25..c525dfccc1ac 100644 --- a/src/Components/Components/test/ComponentBaseTest.cs +++ b/src/Components/Components/test/ComponentBaseTest.cs @@ -387,6 +387,7 @@ public async Task ErrorBoundaryHandlesOnInitializedAsyncReturnFaultedTask() 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); @@ -403,6 +404,94 @@ public async Task ErrorBoundaryHandlesOnInitializedAsyncReturnFaultedTask() 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() { @@ -637,6 +726,17 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) 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"); @@ -644,7 +744,20 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) protected override Task OnInitializedAsync() { - return Task.FromException(new InvalidTimeZoneException()); + if (FaultedTaskOnInitializedAsync) + { + return Task.FromException(new InvalidTimeZoneException()); + } + return Task.CompletedTask; + } + + protected override Task OnParametersSetAsync() + { + if (FaultedTaskOnParametersSetAsync) + { + return Task.FromException(new InvalidTimeZoneException()); + } + return Task.CompletedTask; } } } From 55eb4194bdd613cfa475b886808fe7e8d1445032 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Fri, 15 Aug 2025 12:14:51 +0200 Subject: [PATCH 4/4] Fix test --- .../test/RemoteAuthenticatorCoreTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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]