diff --git a/src/Components/Components/src/Rendering/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs index 3bff9bcfc8..b628983ab7 100644 --- a/src/Components/Components/src/Rendering/ComponentState.cs +++ b/src/Components/Components/src/Rendering/ComponentState.cs @@ -69,6 +69,8 @@ namespace Microsoft.AspNetCore.Components.Rendering CurrentRenderTree.Clear(); renderFragment(CurrentRenderTree); + CurrentRenderTree.AssertTreeIsValid(Component); + var diff = RenderTreeDiffBuilder.ComputeDiff( _renderer, batchBuilder, diff --git a/src/Components/Components/src/Rendering/RenderTreeBuilder.cs b/src/Components/Components/src/Rendering/RenderTreeBuilder.cs index d7b78f4cae..cd375871db 100644 --- a/src/Components/Components/src/Rendering/RenderTreeBuilder.cs +++ b/src/Components/Components/src/Rendering/RenderTreeBuilder.cs @@ -685,6 +685,17 @@ namespace Microsoft.AspNetCore.Components.Rendering public ArrayRange GetFrames() => _entries.ToRange(); + internal void AssertTreeIsValid(IComponent component) + { + if (_openElementIndices.Count > 0) + { + // It's never valid to leave an element/component/region unclosed. Doing so + // could cause undefined behavior in diffing. + ref var invalidFrame = ref _entries.Buffer[_openElementIndices.Peek()]; + throw new InvalidOperationException($"Render output is invalid for component of type '{component.GetType().FullName}'. A frame of type '{invalidFrame.FrameType}' was left unclosed. Do not use try/catch inside rendering logic, because partial output cannot be undone."); + } + } + // Internal for testing internal void ProcessDuplicateAttributes(int first) { diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index d1f8bb5623..249e1df9ee 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -4001,6 +4001,22 @@ namespace Microsoft.AspNetCore.Components.Test requestedType => Assert.Equal(typeof(TestComponent), requestedType)); } + [Fact] + public async Task ThrowsIfComponentProducesInvalidRenderTree() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent(builder => + { + builder.OpenElement(0, "myElem"); + }); + var rootComponentId = renderer.AssignRootComponentId(component); + + // Act/Assert + var ex = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(rootComponentId)); + Assert.StartsWith($"Render output is invalid for component of type '{typeof(TestComponent).FullName}'. A frame of type 'Element' was left unclosed.", ex.Message); + } + private class TestComponentActivator : IComponentActivator where TResult : IComponent, new() { public List RequestedComponentTypes { get; } = new List(); diff --git a/src/Components/Components/test/Rendering/RenderTreeBuilderTest.cs b/src/Components/Components/test/Rendering/RenderTreeBuilderTest.cs index b4ad71cba2..7a2e9c1ab8 100644 --- a/src/Components/Components/test/Rendering/RenderTreeBuilderTest.cs +++ b/src/Components/Components/test/Rendering/RenderTreeBuilderTest.cs @@ -1831,6 +1831,69 @@ namespace Microsoft.AspNetCore.Components.Rendering f => AssertFrame.Attribute(f, "3", "see ya")); } + [Fact] + public void AcceptsClosedFramesAsValid() + { + // Arrange + var builder = new RenderTreeBuilder(); + var component = new TestComponent(); + builder.OpenElement(0, "myElem"); + builder.OpenRegion(1); + builder.OpenComponent(2); + builder.CloseComponent(); + builder.CloseRegion(); + builder.CloseElement(); + + // Act/Assert + // Lack of exception is success + builder.AssertTreeIsValid(component); + } + + [Fact] + public void ReportsUnclosedElementAsInvalid() + { + // Arrange + var builder = new RenderTreeBuilder(); + var component = new TestComponent(); + builder.OpenElement(0, "outerElem"); + builder.OpenElement(1, "innerElem"); + builder.CloseElement(); + + // Act/Assert + var ex = Assert.Throws(() => builder.AssertTreeIsValid(component)); + Assert.StartsWith($"Render output is invalid for component of type '{typeof(TestComponent).FullName}'. A frame of type 'Element' was left unclosed.", ex.Message); + } + + [Fact] + public void ReportsUnclosedComponentAsInvalid() + { + // Arrange + var builder = new RenderTreeBuilder(); + var component = new TestComponent(); + builder.OpenComponent(0); + builder.OpenComponent(1); + builder.CloseComponent(); + + // Act/Assert + var ex = Assert.Throws(() => builder.AssertTreeIsValid(component)); + Assert.StartsWith($"Render output is invalid for component of type '{typeof(TestComponent).FullName}'. A frame of type 'Component' was left unclosed.", ex.Message); + } + + [Fact] + public void ReportsUnclosedRegionAsInvalid() + { + // Arrange + var builder = new RenderTreeBuilder(); + var component = new TestComponent(); + builder.OpenRegion(0); + builder.OpenRegion(1); + builder.CloseRegion(); + + // Act/Assert + var ex = Assert.Throws(() => builder.AssertTreeIsValid(component)); + Assert.StartsWith($"Render output is invalid for component of type '{typeof(TestComponent).FullName}'. A frame of type 'Region' was left unclosed.", ex.Message); + } + private class TestComponent : IComponent { public void Attach(RenderHandle renderHandle) { } @@ -1839,6 +1902,10 @@ namespace Microsoft.AspNetCore.Components.Rendering => throw new NotImplementedException(); } + private class OtherComponent : TestComponent + { + } + private class TestRenderer : Renderer { public TestRenderer() : base(new TestServiceProvider(), NullLoggerFactory.Instance)