diff --git a/src/Components/Components/src/ComponentBase.cs b/src/Components/Components/src/ComponentBase.cs index eefe8eb74655..c87d702e439c 100644 --- a/src/Components/Components/src/ComponentBase.cs +++ b/src/Components/Components/src/ComponentBase.cs @@ -124,7 +124,11 @@ protected void StateHasChanged() { if (_hasPendingQueuedRender) { - return; + if (!_renderHandle.RemoveAllowNextRender()) + { + return; + } + _hasPendingQueuedRender = false; } if (_hasNeverRendered || ShouldRender() || _renderHandle.IsRenderingOnMetadataUpdate) diff --git a/src/Components/Components/src/RenderHandle.cs b/src/Components/Components/src/RenderHandle.cs index edcb644bddfb..61a840ed89de 100644 --- a/src/Components/Components/src/RenderHandle.cs +++ b/src/Components/Components/src/RenderHandle.cs @@ -24,6 +24,15 @@ internal RenderHandle(Renderer renderer, int componentId) internal ComponentsMetrics? ComponentMetrics => _renderer?.ComponentMetrics; internal ComponentsActivitySource? ComponentActivitySource => _renderer?.ComponentActivitySource; + internal bool RemoveAllowNextRender() + { + if (_renderer == null) + { + ThrowNotInitialized(); + } + return _renderer.RemoveAllowNextRender(_componentId); + } + /// /// Gets the associated with the component. /// diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index ad0864443da4..d94fc322b8e9 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -56,6 +56,8 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable private bool _hotReloadInitialized; private HotReloadRenderHandler? _hotReloadRenderHandler; + private HashSet? _componentsAllowNextRender; + /// /// Allows the caller to handle exceptions from the SynchronizationContext when one is available. /// @@ -403,6 +405,17 @@ internal void RegisterComponentState(IComponent component, int componentId, Comp _componentStateByComponent.Add(component, componentState); } + internal void AllowNextRender(int componentId) + { + _componentsAllowNextRender ??= new HashSet(); + _componentsAllowNextRender.Add(componentId); + } + + internal bool RemoveAllowNextRender(int componentId) + { + return _componentsAllowNextRender?.Remove(componentId) ?? false; + } + /// /// Creates a instance to track state associated with a newly-instantiated component. /// This is called before the component is initialized and tracked within the . Subclasses @@ -1176,6 +1189,7 @@ private void HandleExceptionViaErrorBoundary(Exception error, ComponentState? er // making it render an empty fragment. Ensures that failed components don't continue to // operate, which would be a whole new kind of edge case to support forever. AddToRenderQueue(candidate.ComponentId, builder => { }); + AllowNextRender(candidate.ComponentId); try { diff --git a/src/Components/Components/test/ComponentBaseTest.cs b/src/Components/Components/test/ComponentBaseTest.cs index c525dfccc1ac..95c114e4f394 100644 --- a/src/Components/Components/test/ComponentBaseTest.cs +++ b/src/Components/Components/test/ComponentBaseTest.cs @@ -436,6 +436,91 @@ public async Task ErrorBoundaryHandlesCallOnParametersSetAsyncReturnFaultedTask( Assert.Equal(typeof(InvalidTimeZoneException), capturedBoundary!.ReceivedException.GetType()); } + [Fact] + public void ErrorBoundaryStaysInTheErrorStateAfterRerender() + { + // Arrange + var renderer = new TestRenderer(); + TestErrorBoundary capturedBoundary = null; + var shouldThrow = true; + var rootComponent = new TestComponent(); + + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(TestErrorBoundary.ChildContent), (RenderFragment)(builder2 => + { + builder2.OpenComponent(1); + builder2.AddAttribute(2, nameof(TestComponentErrorBuildRenderTree.ThrowDuringRender), shouldThrow); + builder2.CloseComponent(); + })); + builder.AddComponentReferenceCapture(2, obj => capturedBoundary = (TestErrorBoundary)obj); + builder.CloseComponent(); + }; + + // Render first time + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + renderer.RenderRootComponent(rootComponentId); + + Assert.NotNull(capturedBoundary); + Assert.NotNull(capturedBoundary.ReceivedException); + var errorBoundaryId = renderer.Batches.Single().GetComponentFrames().Single().ComponentId; + var frames = renderer.GetCurrentRenderTreeFrames(errorBoundaryId).AsEnumerable(); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "div", subtreeLength: 2), + frame => AssertFrame.Attribute(frame, "class", "blazor-error-boundary")); + + // Render second time with no error + shouldThrow = false; + renderer.RenderRootComponent(rootComponentId); + + Assert.NotNull(capturedBoundary); + Assert.NotNull(capturedBoundary.ReceivedException); + frames = renderer.GetCurrentRenderTreeFrames(errorBoundaryId).AsEnumerable(); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "div", subtreeLength: 2), + frame => AssertFrame.Attribute(frame, "class", "blazor-error-boundary")); + } + + [Fact] + public async Task ErrorBoundaryStaysInTheErrorStateAfterRerenderInTheSameBatch() + { + // Arrange + var renderer = new TestRenderer(); + TestErrorBoundary capturedBoundary = null; + var shouldThrow = true; + var rootComponent = new TestComponent(); + + rootComponent.ChildContent = builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(TestErrorBoundary.ChildContent), (RenderFragment)(builder2 => + { + builder2.OpenComponent(1); + builder2.AddAttribute(2, nameof(TestComponentErrorBuildRenderTree.ThrowDuringRender), shouldThrow); + builder2.CloseComponent(); + + builder2.OpenComponent(2); + builder2.AddAttribute(3, nameof(TestComponentErrorBuildRenderTree.ThrowDuringRender), shouldThrow); + builder2.CloseComponent(); + })); + builder.AddComponentReferenceCapture(2, obj => capturedBoundary = (TestErrorBoundary)obj); + builder.CloseComponent(); + }; + + // Render first time + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + renderer.RenderRootComponent(rootComponentId); + + Assert.NotNull(capturedBoundary); + Assert.NotNull(capturedBoundary.ReceivedException); + var errorBoundaryId = renderer.Batches.Single().GetComponentFrames().Single().ComponentId; + var frames = renderer.GetCurrentRenderTreeFrames(errorBoundaryId).AsEnumerable(); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "div", subtreeLength: 2), + frame => AssertFrame.Attribute(frame, "class", "blazor-error-boundary")); + } + [Fact] public async Task ComponentBaseDoesntRenderWhenOnInitializedAsyncFaultedTask() { @@ -729,6 +814,8 @@ private class TestComponentErrorBuildRenderTree : ComponentBase [Parameter] public bool FaultedTaskOnInitializedAsync { get; set; } = false; [Parameter] public bool FaultedTaskOnParametersSetAsync { get; set; } = false; + [Parameter] public bool ThrowDuringRender { get; set; } = true; + public int StateHasChangedCalled { get; set; } = 0; protected new void StateHasChanged() @@ -739,7 +826,10 @@ private class TestComponentErrorBuildRenderTree : ComponentBase protected override void BuildRenderTree(RenderTreeBuilder builder) { - throw new InvalidOperationException("Error in BuildRenderTree"); + if (ThrowDuringRender) + { + throw new InvalidOperationException("Error in BuildRenderTree"); + } } protected override Task OnInitializedAsync() diff --git a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs index 80402e38bf54..a658ba834595 100644 --- a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs +++ b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs @@ -114,6 +114,16 @@ public void CanHandleErrorsInlineInErrorBoundaryContent() AssertGlobalErrorState(true); } + [Fact] + public void CanHandleMultipleExceptionsForOnce() + { + var container = Browser.Exists(By.Id("throw-multiple-errors-foreach-test")); + container.FindElement(By.ClassName("throw-multiple-errors-foreach")).Click(); + Browser.Collection(() => container.FindElements(By.ClassName("error-message")), + elem => Assert.Equal("There was an error.", elem.Text)); + AssertGlobalErrorState(false); + } + [Fact] public void CanHandleErrorsAfterDisposingComponent() { diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor index 6b1916019d06..c775385128c6 100644 --- a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor @@ -52,6 +52,26 @@ +
+

Error boundary can handle multiple errors thrown

+
+ + + @if (throwMultipleErrorsForeach) + { + @foreach (var item in Enumerable.Range(1, 3)) + { + + } + } + + +

There was an error.

+
+
+ +
+

Exception inline in error boundary markup

This shows that, if an ErrorBoundary itself fails while rendering its own ChildContent, then it can catch its own exception. But if the error comes from the error content, this triggers the "infinite error loop" detection logic and becomes fatal.

@@ -140,6 +160,8 @@ private bool multipleChildrenBeginDelayedError; private bool twoErrorsInChild; + private bool throwMultipleErrorsForeach; + void EventHandlerErrorSync() => throw new InvalidTimeZoneException("Synchronous error from event handler"); diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ForeachErrorsChild.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ForeachErrorsChild.razor new file mode 100644 index 000000000000..e2a1d8209ec5 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ForeachErrorsChild.razor @@ -0,0 +1,8 @@ +

Subcomponent

+ +@code { + protected override void OnInitialized() + { + throw new Exception("Error from foreach child"); + } +}