Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/bunit/Rendering/BunitRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
8 changes: 8 additions & 0 deletions src/bunit/Rendering/Internal/Htmlizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
48 changes: 48 additions & 0 deletions tests/bunit.tests/Rendering/BunitRendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ShowsThrowingChild>();
var trigger = cut.FindComponent<RenderTrigger>();
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<ShowsThrowingChild>();
await cut.Instance.ShowThrowingChild();

cut.FindComponents<RenderTrigger>().Count.ShouldBe(1);
}

internal sealed class LifeCycleMethodInvokeCounter : ComponentBase
{
public int InitilizedCount { get; private set; }
Expand Down Expand Up @@ -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<RenderTrigger>(0);
builder.CloseComponent();
if (showThrowing)
{
builder.OpenComponent<ThrowsOnConstruction>(1);
builder.CloseComponent();
}
}
}

internal sealed class ToggleChild : ComponentBase
{
private bool showing = true;
Expand Down
Loading