diff --git a/CHANGELOG.md b/CHANGELOG.md index 107e84717..e5b9f7677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/bunit/Rendering/RenderedComponent.cs b/src/bunit/Rendering/RenderedComponent.cs index afd868453..22e98e1d2 100644 --- a/src/bunit/Rendering/RenderedComponent.cs +++ b/src/bunit/Rendering/RenderedComponent.cs @@ -18,7 +18,7 @@ internal sealed class RenderedComponent : ComponentState, IRenderedC private readonly BunitHtmlParser htmlParser; private string markup = string.Empty; - private INodeList? latestRenderNodes; + private MarkupNodes? latestRenderNodes; /// /// Gets the component under test. @@ -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; } } @@ -169,4 +179,6 @@ public override ValueTask DisposeAsync() Dispose(); return base.DisposeAsync(); } + + private sealed record MarkupNodes(string Markup, INodeList Nodes); } diff --git a/tests/bunit.tests/Rendering/RenderedComponentTest.cs b/tests/bunit.tests/Rendering/RenderedComponentTest.cs index bdf04d193..f2ab250f4 100644 --- a/tests/bunit.tests/Rendering/RenderedComponentTest.cs +++ b/tests/bunit.tests/Rendering/RenderedComponentTest.cs @@ -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(ps => ps.AddChildContent("

0

")); + _ = cut.Nodes; + cut.Render(ps => ps.AddChildContent("

1

")); + // 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())!; + 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("

2

")); + } + + reader.Join(); + cut.Find("p").TextContent.ShouldBe("2"); + } + private class BaseComponent : ComponentBase { protected override void BuildRenderTree(RenderTreeBuilder builder)