diff --git a/src/Components/Components/test/ComponentBaseTest.cs b/src/Components/Components/test/ComponentBaseTest.cs index b8a928a0ec41..ecb8bf086247 100644 --- a/src/Components/Components/test/ComponentBaseTest.cs +++ b/src/Components/Components/test/ComponentBaseTest.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; namespace Microsoft.AspNetCore.Components.Test; @@ -463,6 +464,1391 @@ public async Task RenderRootComponentAsync_ReportsErrorDuringOnParameterSetAsync Assert.Same(expected, actual); } + [Fact] + public async Task OnInitializedAsync_ThrowsExceptionSynchronouslyUsingAsyncAwait() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { +#pragma warning disable CS1998 // This async method lacks 'await' operators and will run synchronously + OnInitAsyncLogic = async _ => + { + throw expected; // Throws synchronously in async method + } +#pragma warning restore CS1998 // This async method lacks 'await' operators and will run synchronously + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + var actual = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Same(expected, actual); + } + + [Fact] + public async Task OnInitializedAsync_ThrowsExceptionAsynchronouslyUsingAsyncAwait() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + throw expected; // Throws asynchronously in async method + } + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + var actual = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Same(expected, actual); + } + + [Fact] + public async Task OnInitializedAsync_ReturnsTaskFromExceptionSynchronously() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = _ => + { + return Task.FromException(expected); // Returns faulted task synchronously + } + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + var actual = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Same(expected, actual); + } + + [Fact] + public async Task OnInitializedAsync_ReturnsCancelledTaskSynchronously() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = _ => + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + return Task.FromCanceled(cts.Token); // Returns cancelled task synchronously + } + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert - should not throw and should have completed rendering + Assert.NotEmpty(renderer.Batches); + } + + [Fact] + public async Task OnInitializedAsync_ReturnsCancelledTaskAsynchronously() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + var cts = new CancellationTokenSource(); + cts.Cancel(); + await Task.FromCanceled(cts.Token); // Returns cancelled task asynchronously + } + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert - should not throw and should have completed rendering + Assert.NotEmpty(renderer.Batches); + } + + [Fact] + public async Task OnParametersSetAsync_ThrowsExceptionSynchronouslyUsingAsyncAwait() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { +#pragma warning disable CS1998 // This async method lacks 'await' operators and will run synchronously + OnParametersSetAsyncLogic = async _ => + { + throw expected; // Throws synchronously in async method + } +#pragma warning restore CS1998 // This async method lacks 'await' operators and will run synchronously + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + var actual = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Same(expected, actual); + } + + [Fact] + public async Task OnParametersSetAsync_ThrowsExceptionAsynchronouslyUsingAsyncAwait() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + throw expected; // Throws asynchronously in async method + } + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + var actual = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Same(expected, actual); + } + + [Fact] + public async Task OnParametersSetAsync_ReturnsTaskFromExceptionSynchronously() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = _ => + { + return Task.FromException(expected); // Returns faulted task synchronously + } + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + var actual = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Same(expected, actual); + } + + [Fact] + public async Task OnParametersSetAsync_ReturnsCancelledTaskSynchronously() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = _ => + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + return Task.FromCanceled(cts.Token); // Returns cancelled task synchronously + } + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert - should not throw and should have completed rendering + Assert.NotEmpty(renderer.Batches); + } + + [Fact] + public async Task OnParametersSetAsync_ReturnsCancelledTaskAsynchronously() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + var cts = new CancellationTokenSource(); + cts.Cancel(); + await Task.FromCanceled(cts.Token); // Returns cancelled task asynchronously + } + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert - should not throw and should have completed rendering + Assert.NotEmpty(renderer.Batches); + } + + // StateHasChanged tracking tests + [Fact] + public async Task OnInitializedAsync_SucceedsSynchronously_TracksStateHasChanged() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = _ => Task.CompletedTask + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert + Assert.Equal(1, component.StateHasChangedCallCount); // One render + } + + [Fact] + public async Task OnInitializedAsync_SucceedsAsynchronously_TracksStateHasChanged() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + } + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert + Assert.Equal(2, component.StateHasChangedCallCount); // Initial render + async rerender + } + + [Fact] + public async Task OnInitializedAsync_ReturnsCancelledTaskSynchronously_TracksStateHasChanged() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = _ => + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + return Task.FromCanceled(cts.Token); + } + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert + Assert.Equal(1, component.StateHasChangedCallCount); // One render + } + + [Fact] + public async Task OnInitializedAsync_ReturnsCancelledTaskAsynchronously_TracksStateHasChanged() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + var cts = new CancellationTokenSource(); + cts.Cancel(); + await Task.FromCanceled(cts.Token); + } + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert + Assert.Equal(2, component.StateHasChangedCallCount); // Initial render + async rerender + } + + [Fact] + public async Task OnInitializedAsync_ThrowsExceptionSynchronously_TracksStateHasChanged() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = _ => Task.FromException(expected) + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Equal(0, component.StateHasChangedCallCount); // No render due to exception + } + + [Fact] + public async Task OnInitializedAsync_ThrowsExceptionAsynchronously_TracksStateHasChanged() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + throw expected; + } + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Equal(1, component.StateHasChangedCallCount); // Initial render before async exception + } + + [Fact] + public async Task OnInitializedAsync_ThrowsExceptionSynchronouslyUsingAsyncAwait_TracksStateHasChanged() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { +#pragma warning disable CS1998 // This async method lacks 'await' operators and will run synchronously + OnInitAsyncLogic = async _ => + { + throw expected; // Throws synchronously in async method + } +#pragma warning restore CS1998 // This async method lacks 'await' operators and will run synchronously + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Equal(0, component.StateHasChangedCallCount); // No render due to synchronous exception + } + + [Fact] + public async Task OnInitializedAsync_ThrowsExceptionAsynchronouslyUsingAsyncAwait_TracksStateHasChanged() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + throw expected; // Throws asynchronously in async method + } + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Equal(1, component.StateHasChangedCallCount); // Initial render before async exception + } + + [Fact] + public async Task OnInitializedAsync_ReturnsTaskFromExceptionSynchronously_TracksStateHasChanged() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnInitAsyncLogic = _ => + { + return Task.FromException(expected); // Returns faulted task synchronously + } + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Equal(0, component.StateHasChangedCallCount); // No render due to exception + } + + [Fact] + public async Task OnParametersSetAsync_SucceedsSynchronously_TracksStateHasChanged() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = _ => Task.CompletedTask + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert + Assert.Equal(1, component.StateHasChangedCallCount); // One render + } + + [Fact] + public async Task OnParametersSetAsync_SucceedsAsynchronously_TracksStateHasChanged() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + } + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert + Assert.Equal(2, component.StateHasChangedCallCount); // Initial render + async rerender + } + + [Fact] + public async Task OnParametersSetAsync_ReturnsCancelledTaskSynchronously_TracksStateHasChanged() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = _ => + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + return Task.FromCanceled(cts.Token); + } + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert + Assert.Equal(1, component.StateHasChangedCallCount); // One render + } + + [Fact] + public async Task OnParametersSetAsync_ReturnsCancelledTaskAsynchronously_TracksStateHasChanged() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + var cts = new CancellationTokenSource(); + cts.Cancel(); + await Task.FromCanceled(cts.Token); + } + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert + Assert.Equal(2, component.StateHasChangedCallCount); // Initial render + async rerender + } + + [Fact] + public async Task OnParametersSetAsync_ThrowsExceptionSynchronously_TracksStateHasChanged() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = _ => Task.FromException(expected) + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Equal(0, component.StateHasChangedCallCount); // No render due to exception + } + + [Fact] + public async Task OnParametersSetAsync_ThrowsExceptionAsynchronously_TracksStateHasChanged() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + throw expected; + } + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Equal(1, component.StateHasChangedCallCount); // Initial render before async exception + } + + [Fact] + public async Task OnParametersSetAsync_ThrowsExceptionSynchronouslyUsingAsyncAwait_TracksStateHasChanged() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { +#pragma warning disable CS1998 // This async method lacks 'await' operators and will run synchronously + OnParametersSetAsyncLogic = async _ => + { + throw expected; // Throws synchronously in async method + } +#pragma warning restore CS1998 // This async method lacks 'await' operators and will run synchronously + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Equal(0, component.StateHasChangedCallCount); // No render due to synchronous exception + } + + [Fact] + public async Task OnParametersSetAsync_ThrowsExceptionAsynchronouslyUsingAsyncAwait_TracksStateHasChanged() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + throw expected; // Throws asynchronously in async method + } + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Equal(1, component.StateHasChangedCallCount); // Initial render before async exception + } + + [Fact] + public async Task OnParametersSetAsync_ReturnsTaskFromExceptionSynchronously_TracksStateHasChanged() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent + { + OnParametersSetAsyncLogic = _ => + { + return Task.FromException(expected); // Returns faulted task synchronously + } + }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Equal(0, component.StateHasChangedCallCount); // No render due to exception + } + + // ErrorBoundary tests for ComponentBase lifecycle methods + // Each test corresponds to a StateHasChanged tracking test but wrapped in an ErrorBoundary + // The component unconditionally throws in BuildRenderTree to validate error boundary behavior + + [Fact] + public async Task ErrorBoundary_OnInitializedAsync_SucceedsSynchronously_RendersErrorContent() + { + // Arrange + var renderer = new TestRenderer(); + var errorBoundary = new TestErrorBoundaryComponent(); + var component = new TestComponentWithBuildRenderTreeError + { + OnInitAsyncLogic = _ => Task.CompletedTask + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert - ErrorBoundary should have caught the BuildRenderTree exception and rendered error content + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundaryComponent = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundaryComponent.ReceivedException); + Assert.Contains("BuildRenderTree error", errorBoundaryComponent.ReceivedException.Message); + } + + [Fact] + public async Task ErrorBoundary_OnInitializedAsync_SucceedsAsynchronously_RendersErrorContent() + { + // Arrange + var renderer = new TestRenderer(); + var errorBoundary = new TestErrorBoundaryComponent(); + var component = new TestComponentWithBuildRenderTreeError + { + OnInitAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert - ErrorBoundary should have caught the BuildRenderTree exception and rendered error content + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundaryComponent = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundaryComponent.ReceivedException); + Assert.Contains("BuildRenderTree error", errorBoundaryComponent.ReceivedException.Message); + } + + [Fact] + public async Task ErrorBoundary_OnInitializedAsync_ReturnsCancelledTaskSynchronously_RendersErrorContent() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnInitAsyncLogic = _ => + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + return Task.FromCanceled(cts.Token); + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert - ErrorBoundary should have caught the BuildRenderTree exception and rendered error content + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundaryComponent = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundaryComponent.ReceivedException); + Assert.Contains("BuildRenderTree error", errorBoundaryComponent.ReceivedException.Message); + } + + [Fact] + public async Task ErrorBoundary_OnInitializedAsync_ReturnsCancelledTaskAsynchronously_RendersErrorContent() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnInitAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + var cts = new CancellationTokenSource(); + cts.Cancel(); + await Task.FromCanceled(cts.Token); + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert - ErrorBoundary should have caught the BuildRenderTree exception and rendered error content + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundaryComponent = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundaryComponent.ReceivedException); + Assert.Contains("BuildRenderTree error", errorBoundaryComponent.ReceivedException.Message); + } + + [Fact] + public async Task ErrorBoundary_OnInitializedAsync_ThrowsExceptionSynchronously_RendersErrorContent() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.AddComponentParameter(1, nameof(TestComponent.OnInitAsyncLogic), (Func)(_ => Task.FromException(expected))); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundaryComponent = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundaryComponent.ReceivedException); + Assert.IsType(errorBoundaryComponent.ReceivedException); + Assert.Same(expected, errorBoundaryComponent.ReceivedException); + } + + [Fact] + public async Task ErrorBoundary_OnInitializedAsync_ThrowsExceptionAsynchronously_RendersErrorContent() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnInitAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + throw expected; + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundary = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundary.ReceivedException); + Assert.IsType(errorBoundary.ReceivedException); + Assert.Same(expected, errorBoundary.ReceivedException); + + } + + [Fact] + public async Task ErrorBoundary_OnInitializedAsync_ThrowsExceptionSynchronouslyUsingAsyncAwait_RendersErrorContent() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { +#pragma warning disable CS1998 // This async method lacks 'await' operators and will run synchronously + OnInitAsyncLogic = async _ => + { + throw expected; // Throws synchronously in async method + } +#pragma warning restore CS1998 // This async method lacks 'await' operators and will run synchronously + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundary = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundary.ReceivedException); + Assert.IsType(errorBoundary.ReceivedException); + Assert.Same(expected, errorBoundary.ReceivedException); + + } + + [Fact] + public async Task ErrorBoundary_OnInitializedAsync_ThrowsExceptionAsynchronouslyUsingAsyncAwait_RendersErrorContent() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnInitAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + throw expected; // Throws asynchronously in async method + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundary = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundary.ReceivedException); + Assert.IsType(errorBoundary.ReceivedException); + Assert.Same(expected, errorBoundary.ReceivedException); + + } + + [Fact] + public async Task ErrorBoundary_OnInitializedAsync_ReturnsTaskFromExceptionSynchronously_RendersErrorContent() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnInitAsyncLogic = _ => + { + return Task.FromException(expected); // Returns faulted task synchronously + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundary = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundary.ReceivedException); + Assert.IsType(errorBoundary.ReceivedException); + Assert.Same(expected, errorBoundary.ReceivedException); + + } + + [Fact] + public async Task ErrorBoundary_OnParametersSetAsync_SucceedsSynchronously_RendersErrorContent() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnParametersSetAsyncLogic = _ => Task.CompletedTask + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert - ErrorBoundary should have caught the BuildRenderTree exception and rendered error content + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundaryComponent = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundaryComponent.ReceivedException); + Assert.Contains("BuildRenderTree error", errorBoundaryComponent.ReceivedException.Message); + } + + [Fact] + public async Task ErrorBoundary_OnParametersSetAsync_SucceedsAsynchronously_RendersErrorContent() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnParametersSetAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert - ErrorBoundary should have caught the BuildRenderTree exception and rendered error content + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundaryComponent = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundaryComponent.ReceivedException); + Assert.Contains("BuildRenderTree error", errorBoundaryComponent.ReceivedException.Message); + } + + [Fact] + public async Task ErrorBoundary_OnParametersSetAsync_ReturnsCancelledTaskSynchronously_RendersErrorContent() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnParametersSetAsyncLogic = _ => + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + return Task.FromCanceled(cts.Token); + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert - ErrorBoundary should have caught the BuildRenderTree exception and rendered error content + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundaryComponent = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundaryComponent.ReceivedException); + Assert.Contains("BuildRenderTree error", errorBoundaryComponent.ReceivedException.Message); + } + + [Fact] + public async Task ErrorBoundary_OnParametersSetAsync_ReturnsCancelledTaskAsynchronously_RendersErrorContent() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnParametersSetAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + var cts = new CancellationTokenSource(); + cts.Cancel(); + await Task.FromCanceled(cts.Token); + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert - ErrorBoundary should have caught the BuildRenderTree exception and rendered error content + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundaryComponent = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundaryComponent.ReceivedException); + Assert.Contains("BuildRenderTree error", errorBoundaryComponent.ReceivedException.Message); + } + + [Fact] + public async Task ErrorBoundary_OnParametersSetAsync_ThrowsExceptionSynchronously_RendersErrorContent() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnParametersSetAsyncLogic = _ => Task.FromException(expected) + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundary = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundary.ReceivedException); + Assert.IsType(errorBoundary.ReceivedException); + Assert.Same(expected, errorBoundary.ReceivedException); + + } + + [Fact] + public async Task ErrorBoundary_OnParametersSetAsync_ThrowsExceptionAsynchronously_RendersErrorContent() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnParametersSetAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + throw expected; + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundary = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundary.ReceivedException); + Assert.IsType(errorBoundary.ReceivedException); + Assert.Same(expected, errorBoundary.ReceivedException); + + } + + [Fact] + public async Task ErrorBoundary_OnParametersSetAsync_ThrowsExceptionSynchronouslyUsingAsyncAwait_RendersErrorContent() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { +#pragma warning disable CS1998 // This async method lacks 'await' operators and will run synchronously + OnParametersSetAsyncLogic = async _ => + { + throw expected; // Throws synchronously in async method + } +#pragma warning restore CS1998 // This async method lacks 'await' operators and will run synchronously + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundary = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundary.ReceivedException); + Assert.IsType(errorBoundary.ReceivedException); + Assert.Same(expected, errorBoundary.ReceivedException); + + } + + [Fact] + public async Task ErrorBoundary_OnParametersSetAsync_ThrowsExceptionAsynchronouslyUsingAsyncAwait_RendersErrorContent() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnParametersSetAsyncLogic = async _ => + { + await Task.Yield(); // Force async execution + throw expected; // Throws asynchronously in async method + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundary = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundary.ReceivedException); + Assert.IsType(errorBoundary.ReceivedException); + Assert.Same(expected, errorBoundary.ReceivedException); + + } + + [Fact] + public async Task ErrorBoundary_OnParametersSetAsync_ReturnsTaskFromExceptionSynchronously_RendersErrorContent() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponentWithBuildRenderTreeError + { + OnParametersSetAsyncLogic = _ => + { + return Task.FromException(expected); // Returns faulted task synchronously + } + }; + + // Create root component that wraps the test component in an error boundary + var rootComponent = new TestComponent(); + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestErrorBoundaryComponent.ChildContent), (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + // Act + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Assert + var batch = renderer.Batches.Last(); + var errorBoundaryFrames = batch.GetComponentFrames(); + Assert.NotEmpty(errorBoundaryFrames); + + var errorBoundary = (TestErrorBoundaryComponent)errorBoundaryFrames.First().Component; + Assert.NotNull(errorBoundary.ReceivedException); + Assert.IsType(errorBoundary.ReceivedException); + Assert.Same(expected, errorBoundary.ReceivedException); + + } + private class TestComponent : ComponentBase { public bool RunsBaseOnInit { get; set; } = true; @@ -491,11 +1877,23 @@ private class TestComponent : ComponentBase public int Counter { get; set; } + public int StateHasChangedCallCount { get; private set; } + + public RenderFragment ChildContent { get; set; } + protected override void BuildRenderTree(RenderTreeBuilder builder) { - builder.OpenElement(0, "p"); - builder.AddContent(1, Counter); - builder.CloseElement(); + StateHasChangedCallCount++; + if (ChildContent != null) + { + builder.AddContent(0, ChildContent); + } + else + { + builder.OpenElement(0, "p"); + builder.AddContent(1, Counter); + builder.CloseElement(); + } } protected override void OnInitialized() @@ -570,4 +1968,88 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } } + + private class TestComponentWithBuildRenderTreeError : ComponentBase + { + public Action OnInitLogic { get; set; } + + public Func OnInitAsyncLogic { get; set; } + + public Action OnParametersSetLogic { get; set; } + + public Func OnParametersSetAsyncLogic { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + // This component unconditionally throws in BuildRenderTree to test ErrorBoundary behavior + throw new InvalidOperationException("BuildRenderTree error - component always fails to render"); + } + + protected override void OnInitialized() + { + base.OnInitialized(); + OnInitLogic?.Invoke(this); + } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + if (OnInitAsyncLogic != null) + { + await OnInitAsyncLogic.Invoke(this); + } + } + + protected override void OnParametersSet() + { + base.OnParametersSet(); + OnParametersSetLogic?.Invoke(this); + } + + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + if (OnParametersSetAsyncLogic != null) + { + await OnParametersSetAsyncLogic(this); + } + } + } + + private class TestErrorBoundaryComponent : ComponentBase, IErrorBoundary + { + public Exception ReceivedException { get; private set; } + + [Parameter] public RenderFragment ChildContent { get; set; } + + [Parameter] public RenderFragment ErrorContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (ReceivedException is null) + { + builder.AddContent(0, ChildContent); + } + else if (ErrorContent is not null) + { + builder.AddContent(1, ErrorContent(ReceivedException)); + } + else + { + // Default error content + builder.OpenElement(2, "div"); + builder.AddAttribute(3, "class", "error-boundary"); + builder.AddContent(4, "An error has occurred"); + builder.CloseElement(); + } + } + + public void HandleException(Exception exception) + { + ReceivedException = exception; + StateHasChanged(); + } + } } diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 492b5c8cc2f9..bff8e072cca6 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -4779,6 +4779,55 @@ public async Task EventDispatchExceptionsCanBeHandledByClosestErrorBoundary_Afte component => Assert.Same(exception, component.ReceivedException)); } + [Fact] + public void ErrorBoundaryHandlesMultipleExceptionsFromSameComponent() + { + // This test reproduces the issue where a component throws exceptions in both + // parameter setting (lifecycle) and during rendering. + + // Arrange + var renderer = new TestRenderer(); + var exceptionsDuringParameterSetting = new List(); + var exceptionsInErrorBoundary = new List(); + + var rootComponentId = renderer.AssignRootComponentId(new TestComponent(builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(MultipleExceptionsErrorBoundary.ChildContent), (RenderFragment)(builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(ComponentWithMultipleExceptions.SetParametersAction), (Func)(() => throw new Exception("error1"))); + builder.AddComponentParameter(2, nameof(ComponentWithMultipleExceptions.BuildRenderTreeAction), (Action)(_ => throw new Exception("error2"))); + builder.CloseComponent(); + })); + builder.AddComponentParameter(2, nameof(MultipleExceptionsErrorBoundary.ExceptionHandler), (Action)(ex => exceptionsInErrorBoundary.Add(ex))); + builder.CloseComponent(); + })); + + // Act + try + { + renderer.RenderRootComponent(rootComponentId); + } + catch (Exception ex) + { + exceptionsDuringParameterSetting.Add(ex); + } + + // Assert - Let's just print what's happening for now to understand the behavior + var batches = renderer.Batches; + var errorBoundary = batches.FirstOrDefault()?.GetComponentFrames().FirstOrDefault().Component as MultipleExceptionsErrorBoundary; + + // Let's see what actually happened - we expect the first exception (from SetParametersAsync) to be caught and handled + Assert.True(exceptionsDuringParameterSetting.Count > 0 || exceptionsInErrorBoundary.Count > 0, + $"Expected at least one exception to be handled. " + + $"Parameter exceptions: {exceptionsDuringParameterSetting.Count}, " + + $"Error boundary exceptions: {exceptionsInErrorBoundary.Count}, " + + $"Error boundary exists: {errorBoundary != null}, " + + $"Error boundary exception count: {errorBoundary?.ExceptionCount ?? 0}, " + + $"Batches count: {batches.Count}"); + } + [Fact] public async Task CanRemoveRootComponents() { @@ -5996,6 +6045,59 @@ public static void RenderNestedErrorBoundaries(RenderTreeBuilder builder, Render } } + private class ComponentWithMultipleExceptions : ComponentBase + { + [Parameter] public Func SetParametersAction { get; set; } + [Parameter] public Action BuildRenderTreeAction { get; set; } + + public override Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + if (SetParametersAction != null) + { + return SetParametersAction(); + } + + return base.SetParametersAsync(parameters); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (BuildRenderTreeAction != null) + { + BuildRenderTreeAction(builder); + } + } + } + + private class MultipleExceptionsErrorBoundary : ErrorBoundaryBase + { + [Parameter] public Action ExceptionHandler { get; set; } + + public Exception LastException => CurrentException; + public int ExceptionCount { get; private set; } + + protected override Task OnErrorAsync(Exception exception) + { + ExceptionCount++; + ExceptionHandler?.Invoke(exception); + return Task.CompletedTask; + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (CurrentException is not null) + { + builder.AddContent(0, $"Error: {CurrentException.Message}"); + } + else + { + ChildContent?.Invoke(builder); + } + } + } + private class ErrorThrowingComponent : AutoRenderComponent, IHandleEvent { [Parameter] public Exception ThrowDuringRender { get; set; } @@ -6046,6 +6148,33 @@ public async Task HandleEventAsync(EventCallbackWorkItem item, object arg) } } + private class MultiExceptionErrorBoundary : AutoRenderComponent, IErrorBoundary + { + public Exception LastReceivedException { get; private set; } + public int ExceptionCount { get; private set; } + + [Parameter] public RenderFragment ChildContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (LastReceivedException is not null) + { + builder.AddContent(0, $"Error: {LastReceivedException.Message}"); + } + else + { + ChildContent(builder); + } + } + + public void HandleException(Exception error) + { + LastReceivedException = error; + ExceptionCount++; + TriggerRender(); + } + } + private class CallbackDuringSetParametersAsyncComponent : AutoRenderComponent { public int RenderCount { get; private set; } diff --git a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs index 50e6fd06deef..07db791168db 100644 --- a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs +++ b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs @@ -158,6 +158,20 @@ public void CanHandleMultipleAsyncErrorsFromDescendants() AssertGlobalErrorState(false); } + [Fact] + public void CanHandleTwoErrorsInSameComponent() + { + var container = Browser.Exists(By.Id("two-errors-test")); + + // Trigger the component with two errors + container.FindElement(By.Id("show-two-errors-component")).Click(); + + // Should show error content, not be blank + Browser.Exists(By.ClassName("two-errors-error-content")); + + AssertGlobalErrorState(false); + } + void AssertGlobalErrorState(bool hasGlobalError) { var globalErrorUi = Browser.Exists(By.Id("blazor-error-ui")); diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ComponentWithTwoErrors.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ComponentWithTwoErrors.razor new file mode 100644 index 000000000000..3ca6db2403d5 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ComponentWithTwoErrors.razor @@ -0,0 +1,12 @@ +@{ + throw new Exception("error2"); +} + +@code +{ + protected override async Task OnInitializedAsync() + { + await Task.Yield(); // Make it actually async + throw new Exception("error"); + } +} \ No newline at end of file diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor index bcc4aec249e0..ed67a5d0f09b 100644 --- a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor @@ -99,6 +99,24 @@ +
+

Two different errors in same component

+

This tests the case where a component throws in both OnInitializedAsync and during rendering.

+
+ @if (showTwoErrorsComponent) + { + + + + + +
@context
+
+
+ } + +
+

Dispatch exception to renderer

Use DispatchExceptionAsync to see if exceptions are correctly dispatched to the renderer.

@@ -122,6 +140,7 @@ private bool disposalTestRemoveErrorBoundary; private bool disposalTestBeginDelayedError; private bool multipleChildrenBeginDelayedError; + private bool showTwoErrorsComponent; void EventHandlerErrorSync() => throw new InvalidTimeZoneException("Synchronous error from event handler");