From 0213c1973bedff9a9d9eb1c1b2176e4f83d51a19 Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Sun, 13 Sep 2026 12:09:43 +0000 Subject: [PATCH 01/17] Set version to '2.12-preview' --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 23c3aaf0f..b6ff506b5 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.11-preview", + "version": "2.12-preview", "assemblyVersion": { "precision": "revision" }, From 0b9d00c9941a130135cc5d27fd4e37d9fec65847 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Wed, 16 Sep 2026 21:44:02 +0200 Subject: [PATCH 02/17] chore: Update packages --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 04c706758..93b2ce6d4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -105,9 +105,9 @@ - - - + + + From 8a9f139fa61d65d8e4a7b27626e492c1ddab078f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:14:43 +0000 Subject: [PATCH 03/17] Bump AngleSharp from 1.8.1 to 1.8.2 --- updated-dependencies: - dependency-name: AngleSharp dependency-version: 1.8.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 93b2ce6d4..a6f59b79f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + From cbc84e67e3a89cb7f323d5aa178d6e383bde4232 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:14:49 +0000 Subject: [PATCH 04/17] Bump docfx from 2.78.5 to 2.80.1 --- updated-dependencies: - dependency-name: docfx dependency-version: 2.80.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index bfb6e7112..bd4ec3375 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,7 +10,7 @@ "rollForward": false }, "docfx": { - "version": "2.78.5", + "version": "2.80.1", "commands": [ "docfx" ], From 718b4d15c647e2a8c60c2e61f8d377a61531c080 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:23:56 +0000 Subject: [PATCH 05/17] Bump Microsoft.NET.Test.Sdk from 18.10.0 to 18.10.1 --- updated-dependencies: - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.10.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a6f59b79f..3e1c9b68c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -91,7 +91,7 @@ - + From 2abc4d46966fe5f6972757fbd010b36fc93f3b72 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 16 Oct 2026 10:07:07 +0200 Subject: [PATCH 06/17] chore: Update packages and vuln --- Directory.Packages.props | 103 +++++++++--------- .../bunit.generators.tests.csproj | 1 + 2 files changed, 53 insertions(+), 51 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3e1c9b68c..cde71da89 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + @@ -18,8 +18,8 @@ - - + + @@ -29,61 +29,61 @@ - + - - + + - - - + + + - + - - - - - - - - - - - - + + + + + + + + + + + + - - - + + + - - - - - - + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + @@ -100,15 +100,15 @@ - - - - + + + + - - - - + + + + @@ -117,5 +117,6 @@ + diff --git a/tests/bunit.generators.tests/bunit.generators.tests.csproj b/tests/bunit.generators.tests/bunit.generators.tests.csproj index 28d889510..1b0d6eded 100644 --- a/tests/bunit.generators.tests/bunit.generators.tests.csproj +++ b/tests/bunit.generators.tests/bunit.generators.tests.csproj @@ -21,6 +21,7 @@ + From da7e79e5bdce47d92d6b6adf766b7482927cac02 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 16 Oct 2026 10:20:55 +0200 Subject: [PATCH 07/17] fix: rc2 --- tests/bunit.testassets/.globalconfig | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 tests/bunit.testassets/.globalconfig diff --git a/tests/bunit.testassets/.globalconfig b/tests/bunit.testassets/.globalconfig new file mode 100644 index 000000000..0cf91a1c0 --- /dev/null +++ b/tests/bunit.testassets/.globalconfig @@ -0,0 +1,5 @@ +# Razor SDK 11 moves component declarations into *_razor.decl.g.cs, where Sonar flags @implements IDisposable. +# .editorconfig path sections do not match generated files, so this must be global. +is_global = true + +dotnet_diagnostic.S3881.severity = none From 945163b2ee831ca5b43bb9cfa18479a0f4f3ff89 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 16 Oct 2026 09:44:54 +0200 Subject: [PATCH 08/17] fix: DisposeComponentsAsync disposes components while the dispatcher is busy (fixes #1939) Claude-Session: https://claude.ai/code/session_019HVpjMG4nkcwsQQXg2aegk --- CHANGELOG.md | 4 +++ src/bunit/Rendering/BunitRenderer.cs | 41 ++++++++------------------- tests/bunit.tests/BunitContextTest.cs | 22 ++++++++++++++ 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e732dd9da..c9769a6f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +### 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). + ## [2.11.3] - 2026-09-13 ### Fixed diff --git a/src/bunit/Rendering/BunitRenderer.cs b/src/bunit/Rendering/BunitRenderer.cs index 88740fcbc..102596997 100644 --- a/src/bunit/Rendering/BunitRenderer.cs +++ b/src/bunit/Rendering/BunitRenderer.cs @@ -281,27 +281,21 @@ public IReadOnlyList> FindComponents( public Task DisposeComponents() { ObjectDisposedException.ThrowIf(disposed, this); - Task? returnTask; - lock (renderTreeUpdateLock) + return Dispatcher.InvokeAsync(async () => { - returnTask = Dispatcher.InvokeAsync(async () => - { - ResetUnhandledException(); - - foreach (var root in rootComponents) - { - root.Detach(); - } - - await Task.WhenAll(disposalTasks).ConfigureAwait(false); - disposalTasks.Clear(); - }); + ResetUnhandledException(); + var roots = rootComponents.ToArray(); rootComponents.Clear(); - } - return returnTask; + foreach (var root in roots) + { + root.Detach(); + } + + await Task.WhenAll(disposalTasks.ToArray()).ConfigureAwait(false); + }); } /// @@ -425,21 +419,11 @@ protected override void AddPendingTask(ComponentState? componentState, Task task if (componentState is null) { ArgumentNullException.ThrowIfNull(task); - AddDisposalTaskToQueue(); + disposalTasks.RemoveAll(static t => t.IsCompleted); + disposalTasks.Add(task); } base.AddPendingTask(componentState, task); - - void AddDisposalTaskToQueue() - { - var t = task; - t = task.ContinueWith(_ => - { - disposalTasks.Remove(t); - }, TaskScheduler.Current); - - disposalTasks.Add(t); - } } internal Task SetDirectParametersAsync(IRenderedComponent renderedComponent, ParameterView parameters) @@ -602,7 +586,6 @@ protected override void Dispose(bool disposing) if (disposing) { returnedRenderedComponentIds.Clear(); - disposalTasks.Clear(); unhandledExceptionTsc.TrySetCanceled(); } diff --git a/tests/bunit.tests/BunitContextTest.cs b/tests/bunit.tests/BunitContextTest.cs index cdbc74601..e9863752d 100644 --- a/tests/bunit.tests/BunitContextTest.cs +++ b/tests/bunit.tests/BunitContextTest.cs @@ -52,6 +52,28 @@ public async Task Test104() callStack.Count.ShouldBe(1); } + [Fact(DisplayName = "DisposeComponents disposes components while the dispatcher is busy")] + public async Task Test105() + { + var callStack = new List(); + Render(ps => ps.Add(p => p.CallStack, callStack)); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var blocker = Task.Run(() => Renderer.Dispatcher.InvokeAsync(() => + { + entered.Set(); + release.Wait(); + }), Xunit.TestContext.Current.CancellationToken); + entered.Wait(Xunit.TestContext.Current.CancellationToken); + + var disposeTask = DisposeComponentsAsync(); + release.Set(); + await disposeTask; + await blocker; + + callStack.Count.ShouldBe(1); + } + [Fact(DisplayName = "The test service provider should register a placeholder HttpClient which throws exceptions")] public void Test024() { From 57243beacd4b84a3f4551f737d37441e4e7ce831 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 18 Oct 2026 18:05:16 +0200 Subject: [PATCH 09/17] 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; From f9e84fbaff391a43bcc34b53cf86b2633f91817d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:14:22 +0000 Subject: [PATCH 10/17] Bump dotnet-dump from 10.0.745401 to 10.0.750501 --- updated-dependencies: - dependency-name: dotnet-dump dependency-version: 10.0.750501 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index bd4ec3375..32a36e980 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "dotnet-dump": { - "version": "10.0.745401", + "version": "10.0.750501", "commands": [ "dotnet-dump" ], From c2ce58643096fb7604afcd1795bc183bbe89de43 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:14:21 +0000 Subject: [PATCH 11/17] Bump docfx from 2.80.1 to 2.81.0 --- updated-dependencies: - dependency-name: docfx dependency-version: 2.81.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 32a36e980..3825e07db 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,7 +10,7 @@ "rollForward": false }, "docfx": { - "version": "2.80.1", + "version": "2.81.0", "commands": [ "docfx" ], From 1c0ce4dffbe6f5850e459ffa3f395871ceaf9291 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:21:57 +0000 Subject: [PATCH 12/17] Bump Microsoft.NET.Test.Sdk from 18.10.1 to 18.11.0 --- updated-dependencies: - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.11.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index cde71da89..0623e633a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -91,7 +91,7 @@ - + From 6126e6aea80d0894c49c3f44594ab652b5a61700 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:26:28 +0000 Subject: [PATCH 13/17] Bump Verify.XunitV3 from 32.0.0 to 33.3.4 --- updated-dependencies: - dependency-name: Verify.XunitV3 dependency-version: 33.3.4 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0623e633a..8a23376ef 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -99,7 +99,7 @@ - + From bd45ca6f8eb06a6a6b29548291c42c0728090753 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:19:05 +0000 Subject: [PATCH 14/17] fix: configure Verify sponsorship exemption Co-authored-by: linkdotnet <26365461+linkdotnet@users.noreply.github.com> --- tests/bunit.generators.tests/bunit.generators.tests.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/bunit.generators.tests/bunit.generators.tests.csproj b/tests/bunit.generators.tests/bunit.generators.tests.csproj index 1b0d6eded..96775d3d7 100644 --- a/tests/bunit.generators.tests/bunit.generators.tests.csproj +++ b/tests/bunit.generators.tests/bunit.generators.tests.csproj @@ -2,6 +2,8 @@ net8.0;net9.0;net10.0;net11.0 + OpenSource + 2027-10 Bunit Bunit.Generator.Tests true From 69ad2115e1ad5389d820330758115fb37e4e26a5 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 18 Oct 2026 18:56:22 +0200 Subject: [PATCH 15/17] fix: latestRenderNodes was not thread-safe (fixes #1938) (#1948) --- CHANGELOG.md | 1 + src/bunit/Rendering/RenderedComponent.cs | 16 +++++++++++-- .../Rendering/RenderedComponentTest.cs | 23 +++++++++++++++++++ 3 files changed, 38 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..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) From 6067580894ec0c441b092e08a4ecb0ae28cd26ae Mon Sep 17 00:00:00 2001 From: Evan Li Date: Mon, 19 Oct 2026 07:01:38 -0400 Subject: [PATCH 16/17] fix: prevent null WrappedElement during concurrent component re-renders (#1946) --- CHANGELOG.md | 1 + .../Web.AngleSharp/WrapperBase.cs | 8 +--- ...orTest.Generator#WrapperBase.g.verified.cs | 10 +--- .../RefreshingWrappedElementTest.cs | 47 +++++++++++++++++++ 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b9f7677..589d75cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to **bUnit** will be documented in this file. The project ad ### Fixed +- `WrapperBase.WrappedElement` could return `null` and throw `NullReferenceException` when an element was accessed concurrently with component re-renders. Reported by [@psu-prog](https://github.com/psu-prog) in #1937. Fixed by [@Nacl-HCl](https://github.com/Nacl-HCl). - `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). diff --git a/src/bunit.generators.internal/Web.AngleSharp/WrapperBase.cs b/src/bunit.generators.internal/Web.AngleSharp/WrapperBase.cs index 8dd4d5963..46bcc12f2 100644 --- a/src/bunit.generators.internal/Web.AngleSharp/WrapperBase.cs +++ b/src/bunit.generators.internal/Web.AngleSharp/WrapperBase.cs @@ -24,13 +24,7 @@ public abstract class WrapperBase : IElementWrapper public TElement WrappedElement { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get - { - if (element is null) - element = elementFactory.GetElement(); - - return element; - } + get => element ??= elementFactory.GetElement(); } /// diff --git a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperBase.g.verified.cs b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperBase.g.verified.cs index 2f8cc5e05..6135001c7 100644 --- a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperBase.g.verified.cs +++ b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperBase.g.verified.cs @@ -1,4 +1,4 @@ -//HintName: WrapperBase.g.cs +//HintName: WrapperBase.g.cs #nullable enable using System.CodeDom.Compiler; using System.Diagnostics; @@ -25,13 +25,7 @@ public abstract class WrapperBase : IElementWrapper public TElement WrappedElement { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get - { - if (element is null) - element = elementFactory.GetElement(); - - return element; - } + get => element ??= elementFactory.GetElement(); } /// diff --git a/tests/bunit.tests/Extensions/RefreshingWrappedElementTest.cs b/tests/bunit.tests/Extensions/RefreshingWrappedElementTest.cs index 4f1602435..e432b470b 100644 --- a/tests/bunit.tests/Extensions/RefreshingWrappedElementTest.cs +++ b/tests/bunit.tests/Extensions/RefreshingWrappedElementTest.cs @@ -76,6 +76,53 @@ public void Test031() Should.Throw(() => btn.TextContent); } + [Fact(DisplayName = "Found element is never null when accessed concurrently with component re-renders")] + public async Task Test040() + { + var cut = Render(); + var elm = cut.Find("span"); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(Xunit.TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(1)); + var renders = Task.Run(async () => + { + while (!cts.IsCancellationRequested) + { + await cut.InvokeAsync(cut.Instance.Increment); + } + }, Xunit.TestContext.Current.CancellationToken); + + try + { + while (!cts.IsCancellationRequested) + { + elm.TextContent.ShouldNotBeNull(); + } + } + finally + { + await cts.CancelAsync(); + await renders; + } + } + + private sealed class Counter : ComponentBase + { + private int count; + + public void Increment() + { + count++; + StateHasChanged(); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "span"); + builder.AddContent(1, count); + builder.CloseElement(); + } + } + private sealed class Markup : ComponentBase { [Parameter] public string Base { get; set; } = string.Empty; From 1e58514ef7a2556e820628f2f4a8b07192c94055 Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Mon, 19 Oct 2026 11:26:21 +0000 Subject: [PATCH 17/17] Set version to '2.12' --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index b6ff506b5..20c1c480d 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.12-preview", + "version": "2.12", "assemblyVersion": { "precision": "revision" },