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() {