From 3fbe3b047d5d12faaa85b6dc97abe714e525c271 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 18 Oct 2026 18:05:16 +0200 Subject: [PATCH] fix: Check for loops and invalide children (fixes #1935) --- CHANGELOG.md | 1 + src/bunit/Rendering/BunitRenderer.cs | 2 +- src/bunit/Rendering/Internal/Htmlizer.cs | 8 ++++ .../Rendering/BunitRendererTest.cs | 48 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9769a6f7..107e84717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to **bUnit** will be documented in this file. The project ad ### Fixed - `DisposeComponentsAsync()` could dispose nothing when the renderer's dispatcher was busy, e.g. with a timer calling `InvokeAsync`. Reported by [@xfunc-tim-schneider](https://github.com/xfunc-tim-schneider) in #1939. Fixed by [@linkdotnet](https://github.com/linkdotnet). +- Markup generation and `FindComponents` could recurse until the test host crashed with a stack overflow when a render threw while instantiating a child component. Reported in #1935. Fixed by [@linkdotnet](https://github.com/linkdotnet). ## [2.11.3] - 2026-09-13 diff --git a/src/bunit/Rendering/BunitRenderer.cs b/src/bunit/Rendering/BunitRenderer.cs index 102596997..46fedfb45 100644 --- a/src/bunit/Rendering/BunitRenderer.cs +++ b/src/bunit/Rendering/BunitRenderer.cs @@ -661,7 +661,7 @@ void FindComponentsInRenderTree(int componentId) for (var i = 0; i < frames.Count; i++) { ref var frame = ref frames.Array[i]; - if (frame.FrameType == RenderTreeFrameType.Component) + if (frame.FrameType == RenderTreeFrameType.Component && frame.Component is not null) { if (frame.Component is TComponent) { diff --git a/src/bunit/Rendering/Internal/Htmlizer.cs b/src/bunit/Rendering/Internal/Htmlizer.cs index 9f31c4bd0..32bb5768e 100644 --- a/src/bunit/Rendering/Internal/Htmlizer.cs +++ b/src/bunit/Rendering/Internal/Htmlizer.cs @@ -131,6 +131,14 @@ int position ) { var frame = frames.Array[position]; + + // A component frame is left uninstantiated (ComponentId 0) when the render that added it threw mid-diff. + // Following that id would recurse into the first root component, which can be an ancestor. + if (frame.Component is null) + { + return position + frame.ComponentSubtreeLength; + } + var childFrames = context.GetRenderTreeFrames(frame.ComponentId); RenderFrames(context, childFrames, 0, childFrames.Count); return position + frame.ComponentSubtreeLength; diff --git a/tests/bunit.tests/Rendering/BunitRendererTest.cs b/tests/bunit.tests/Rendering/BunitRendererTest.cs index 681140d43..fe0c0356b 100644 --- a/tests/bunit.tests/Rendering/BunitRendererTest.cs +++ b/tests/bunit.tests/Rendering/BunitRendererTest.cs @@ -450,6 +450,27 @@ public void Test211() cut.Find("h1").TextContent.ShouldBe($"Hello world {RenderMode.InteractiveWebAssembly}"); } + [Fact(DisplayName = "Markup generation skips child components whose instantiation threw during a render")] + public async Task Test212() + { + var cut = Render(); + var trigger = cut.FindComponent(); + await cut.Instance.ShowThrowingChild(); + + await trigger.Instance.TriggerWithValue("after"); + + cut.Markup.ShouldBe("after"); + } + + [Fact(DisplayName = "FindComponents skips child components whose instantiation threw during a render")] + public async Task Test213() + { + var cut = Render(); + await cut.Instance.ShowThrowingChild(); + + cut.FindComponents().Count.ShouldBe(1); + } + internal sealed class LifeCycleMethodInvokeCounter : ComponentBase { public int InitilizedCount { get; private set; } @@ -532,6 +553,33 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } + internal sealed class ThrowsOnConstruction : ComponentBase + { + public ThrowsOnConstruction() => throw new InvalidOperationException("THROWS ON PURPOSE"); + } + + internal sealed class ShowsThrowingChild : ComponentBase + { + private bool showThrowing; + + public Task ShowThrowingChild() + { + showThrowing = true; + return InvokeAsync(StateHasChanged); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.CloseComponent(); + if (showThrowing) + { + builder.OpenComponent(1); + builder.CloseComponent(); + } + } + } + internal sealed class ToggleChild : ComponentBase { private bool showing = true;