From db3d0d35813c1b25a767a102c8ed6f8e9fac9532 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 18 Oct 2026 18:18:49 +0200 Subject: [PATCH 1/2] fix: latestRenderNodes was not thread-safe (fixes #1938) Claude-Session: https://claude.ai/code/session_01EM3oiTG4jFbWyupY43WuPu --- CHANGELOG.md | 1 + src/bunit/Rendering/RenderedComponent.cs | 16 ++++++++++++++-- .../Rendering/RenderedComponentTest.cs | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) 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..84061cf95 100644 --- a/tests/bunit.tests/Rendering/RenderedComponentTest.cs +++ b/tests/bunit.tests/Rendering/RenderedComponentTest.cs @@ -297,6 +297,23 @@ public void Test029() results.ShouldBeEmpty(); } + [Fact(DisplayName = "Nodes reflects the latest markup after a render on another thread (#1938)")] + public async Task Test030() + { + var cut = Render(ps => ps.AddChildContent("

0

")); + + for (var i = 1; i <= 50; i++) + { + var content = $"

{i}

"; + var render = Task.Run(() => cut.Render(ps => ps.AddChildContent(content)), Xunit.TestContext.Current.CancellationToken); + while (!render.IsCompleted) + _ = cut.Nodes; + await render; + + cut.Find("p").TextContent.ShouldBe(i.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + } + private class BaseComponent : ComponentBase { protected override void BuildRenderTree(RenderTreeBuilder builder) From b1292bc400ba0ba12d9457ec63efcc783e3dab10 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 18 Oct 2026 18:27:05 +0200 Subject: [PATCH 2/2] test: make #1938 regression test deterministic Claude-Session: https://claude.ai/code/session_01EM3oiTG4jFbWyupY43WuPu --- .../Rendering/RenderedComponentTest.cs | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/bunit.tests/Rendering/RenderedComponentTest.cs b/tests/bunit.tests/Rendering/RenderedComponentTest.cs index 84061cf95..f2ab250f4 100644 --- a/tests/bunit.tests/Rendering/RenderedComponentTest.cs +++ b/tests/bunit.tests/Rendering/RenderedComponentTest.cs @@ -298,20 +298,26 @@ public void Test029() } [Fact(DisplayName = "Nodes reflects the latest markup after a render on another thread (#1938)")] - public async Task Test030() + public void Test030() { var cut = Render(ps => ps.AddChildContent("

0

")); - - for (var i = 1; i <= 50; i++) + _ = 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) { - var content = $"

{i}

"; - var render = Task.Run(() => cut.Render(ps => ps.AddChildContent(content)), Xunit.TestContext.Current.CancellationToken); - while (!render.IsCompleted) - _ = cut.Nodes; - await render; - - cut.Find("p").TextContent.ShouldBe(i.ToString(System.Globalization.CultureInfo.InvariantCulture)); + 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