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 @@ -10,6 +10,7 @@ All notable changes to **bUnit** will be documented in this file. The project ad

- `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).
- `Find()` could keep returning DOM nodes from the previous render when a render, e.g. from a timer, happened while the nodes were parsed, causing `UnknownEventHandlerIdException`. Reported by [@xfunc-tim-schneider](https://github.com/xfunc-tim-schneider) in #1938. Fixed by [@linkdotnet](https://github.com/linkdotnet).

## [2.11.3] - 2026-09-13

Expand Down
16 changes: 14 additions & 2 deletions src/bunit/Rendering/RenderedComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ internal sealed class RenderedComponent<TComponent> : ComponentState, IRenderedC
private readonly BunitHtmlParser htmlParser;

private string markup = string.Empty;
private INodeList? latestRenderNodes;
private MarkupNodes? latestRenderNodes;

/// <summary>
/// Gets the component under test.
Expand Down Expand Up @@ -68,7 +68,17 @@ public INodeList Nodes
get
{
EnsureComponentNotDisposed();
return latestRenderNodes ??= htmlParser.Parse(Markup);
// A render can finish while Markup is parsed; keying on the markup reference stops that stale parse from being cached (#1938).
// Not needed in v3: the shared render tree (#1910) binds nodes to an immutable markup snapshot.
var currentMarkup = Markup;
var cached = latestRenderNodes;
if (cached is null || !ReferenceEquals(cached.Markup, currentMarkup))
{
cached = new MarkupNodes(currentMarkup, htmlParser.Parse(currentMarkup));
latestRenderNodes = cached;
}

return cached.Nodes;
}
}

Expand Down Expand Up @@ -169,4 +179,6 @@ public override ValueTask DisposeAsync()
Dispose();
return base.DisposeAsync();
}

private sealed record MarkupNodes(string Markup, INodeList Nodes);
}
23 changes: 23 additions & 0 deletions tests/bunit.tests/Rendering/RenderedComponentTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,29 @@ public void Test029()
results.ShouldBeEmpty();
}

[Fact(DisplayName = "Nodes reflects the latest markup after a render on another thread (#1938)")]
public void Test030()
{
var cut = Render<Wrapper>(ps => ps.AddChildContent("<p>0</p>"));
_ = cut.Nodes;
cut.Render(ps => ps.AddChildContent("<p>1</p>"));
// Holding the parser's lock pauses the reader after it captured the old markup but before it caches the parsed nodes.
var parserLock = typeof(BunitHtmlParser)
.GetField("parserLock", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(Services.GetRequiredService<BunitHtmlParser>())!;
var reader = new Thread(() => _ = cut.Nodes);

lock (parserLock)
{
reader.Start();
SpinWait.SpinUntil(() => reader.ThreadState.HasFlag(ThreadState.WaitSleepJoin), TimeSpan.FromSeconds(5)).ShouldBeTrue();
cut.Render(ps => ps.AddChildContent("<p>2</p>"));
}

reader.Join();
cut.Find("p").TextContent.ShouldBe("2");
}

private class BaseComponent : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder builder)
Expand Down
Loading