Skip to content

Commit a68ff32

Browse files
committed
feat: Add flag to actively wait for async disposal (#1415)
1 parent 9599046 commit a68ff32

File tree

8 files changed

+102
-57
lines changed

8 files changed

+102
-57
lines changed

MIGRATION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,6 @@ Or use the `BunitContext` directly and manage the lifecycle yourself.
7979

8080
## `TestServiceProvider` renamed to `BunitTestServiceProvider`
8181
The `TestServiceProvider` class has been renamed to `BunitTestServiceProvider`. If you used `TestServiceProvider`, you should replace it with `BunitTestServiceProvider`.
82+
83+
## `DisposeComponents` is now asynchronous and called `DisposeComponentsAsync`
84+
`DisposeComponentsAsync` allows to await `DisposeAsync` of components under test. If you used `DisposeComponents`, you should replace it with `DisposeComponentsAsync`.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
@using Microsoft.JSInterop
2+
@implements IAsyncDisposable
3+
@inject IJSRuntime JSRuntime
4+
@code {
5+
6+
public async ValueTask DisposeAsync()
7+
{
8+
await JSRuntime.InvokeVoidAsync("dispose");
9+
}
10+
}

docs/samples/tests/xunit/DisposeComponentsTest.cs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,48 @@ namespace Bunit.Docs.Samples;
88
public class DisposeComponentsTest : BunitContext
99
{
1010
[Fact]
11-
public void DisposeElements()
11+
public async Task DisposeElements()
1212
{
1313
var calledTimes = 0;
1414
var cut = Render<DisposableComponent>(parameters => parameters
1515
.Add(p => p.LocationChangedCallback, url => calledTimes++)
1616
);
1717

18-
DisposeComponents();
18+
await DisposeComponentsAsync();
1919

2020
Services.GetRequiredService<NavigationManager>().NavigateTo("newurl");
2121

2222
Assert.Equal(0, calledTimes);
2323
}
2424

2525
[Fact]
26-
public void ShouldCatchExceptionInDispose()
26+
public async Task ShouldCatchExceptionInDispose()
2727
{
2828
Render<ExceptionInDisposeComponent>();
2929

30-
var act = DisposeComponents;
30+
Func<Task> act = () => DisposeComponentsAsync();
3131

32-
Assert.Throws<NotSupportedException>(act);
32+
await Assert.ThrowsAsync<NotSupportedException>(act);
3333
}
3434

3535
[Fact]
36-
public void ShouldCatchExceptionInDisposeAsync()
36+
public async Task ShouldCatchExceptionInDisposeAsync()
3737
{
3838
Render<ExceptionInDisposeAsyncComponent>();
3939

40-
DisposeComponents();
40+
await DisposeComponentsAsync();
4141
var exception = Renderer.UnhandledException.Result;
4242
Assert.IsType<NotSupportedException>(exception);
4343
}
44+
45+
[Fact]
46+
public async Task ShouldDisposeJSObject()
47+
{
48+
JSInterop.SetupVoid("dispose").SetVoidResult();
49+
Render<AsyncDisposableComponent>();
50+
51+
await DisposeComponentsAsync();
52+
53+
JSInterop.VerifyInvoke("dispose");
54+
}
4455
}

docs/site/docs/interaction/dispose-components.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,33 @@ title: Disposing components and their children
44
---
55

66
# Disposing components
7-
To dispose all components rendered with a `BunitContext`, use the [`DisposeComponents`](xref:Bunit.BunitContext.DisposeComponents) method. Calling this method will dispose all rendered components, calling any `IDisposable.Dispose` or/and `IAsyncDisposable.DisposeAsync` methods they might have, and remove the components from the render tree, starting with the root components and then walking down the render tree to all the child components.
7+
To dispose all components rendered with a `BunitContext`, use the [`DisposeComponents`](xref:Bunit.BunitContext.DisposeComponentsAsync) method. Calling this method will dispose all rendered components, calling any `IDisposable.Dispose` or/and `IAsyncDisposable.DisposeAsync` methods they might have, and remove the components from the render tree, starting with the root components and then walking down the render tree to all the child components.
88

99
Disposing rendered components enables testing of logic in `Dispose` methods, e.g., event handlers, that should be detached to avoid memory leaks.
1010

1111
The following example of this:
1212

13-
[!code-csharp[](../../../samples/tests/xunit/DisposeComponentsTest.cs#L13-L22)]
13+
[!code-csharp[DisposeComponentsTest.cs](../../../samples/tests/xunit/DisposeComponentsTest.cs#L13-L22)]
1414

1515
> [!WARNING]
1616
> For `IAsyncDisposable` (since .net5) relying on [`WaitForState()`](xref:Bunit.RenderedFragmentWaitForHelperExtensions.WaitForState(Bunit.RenderedFragment,System.Func{System.Boolean},System.Nullable{System.TimeSpan})) or [`WaitForAssertion()`](xref:Bunit.RenderedFragmentWaitForHelperExtensions.WaitForAssertion(Bunit.RenderedFragment,System.Action,System.Nullable{System.TimeSpan})) will not work as a disposed component will not trigger a new render cycle.
1717
18+
## Disposing components asynchronously
19+
If a component implements `IAsyncDisposable`, `DisposeComponentsAsync` can be awaited to wait for all asynchronous `DisposeAsync` methods. Sometimes interacting with JavaScript in Blazor WebAssembly requires disposing or resetting state in `DisposeAsync`.
20+
21+
[!code-csharp[DisposeComponentsTest.cs](../../../samples/tests/xunit/DisposeComponentsTest.cs#L48-L53)]
22+
23+
To omit this behavior, discard the returned task
24+
25+
```csharp
26+
_ = DisposeComponentsAsync();
27+
```
28+
1829
## Checking for exceptions
19-
`Dispose` as well as `DisposeAsync` can throw exceptions which can be asserted as well. If a component under test throws an exception in `Dispose` the [`DisposeComponents`](xref:Bunit.BunitContext.DisposeComponents) will throw the exception to the user code:
30+
`Dispose` as well as `DisposeAsync` can throw exceptions which can be asserted as well. If a component under test throws an exception in `Dispose` the [`DisposeComponents`](xref:Bunit.BunitContext.DisposeComponentsAsync) will throw the exception to the user code:
2031

21-
[!code-csharp[](../../../samples/tests/xunit/DisposeComponentsTest.cs#L28-L32)]
32+
[!code-csharp[DisposeComponentsTest.cs](../../../samples/tests/xunit/DisposeComponentsTest.cs#L28-L32)]
2233

2334
`DisposeAsync` behaves a bit different. The following example will demonstrate how to assert an exception in `DisposeAsync`:
2435

25-
[!code-csharp[](../../../samples/tests/xunit/DisposeComponentsTest.cs#L39-L43)]
36+
[!code-csharp[DisposeComponentsTest.cs](../../../samples/tests/xunit/DisposeComponentsTest.cs#L38-L42)]

src/bunit/BunitContext.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,7 @@ protected virtual void Dispose(bool disposing)
130130
/// <summary>
131131
/// Disposes all components rendered via this <see cref="BunitContext"/>.
132132
/// </summary>
133-
public void DisposeComponents()
134-
{
135-
Renderer.DisposeComponents();
136-
}
133+
public Task DisposeComponentsAsync() => Renderer.DisposeComponents();
137134

138135
/// <summary>
139136
/// Instantiates and performs a first render of a component of type <typeparamref name="TComponent"/>.

src/bunit/Rendering/BunitRenderer.cs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ namespace Bunit.Rendering;
1111
public sealed class BunitRenderer : Renderer
1212
{
1313
private readonly BunitServiceProvider services;
14+
private readonly List<Task> disposalTasks = [];
1415

1516
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_isBatchInProgress")]
1617
private static extern ref bool GetIsBatchInProgressField(Renderer renderer);
@@ -178,29 +179,32 @@ public IReadOnlyList<RenderedComponent<TComponent>> FindComponents<TComponent>(R
178179
/// <summary>
179180
/// Disposes all components rendered by the <see cref="BunitRenderer" />.
180181
/// </summary>
181-
public void DisposeComponents()
182+
public Task DisposeComponents()
182183
{
183184
ObjectDisposedException.ThrowIf(disposed, this);
184185

186+
Task? returnTask;
187+
185188
lock (renderTreeUpdateLock)
186189
{
187-
// The dispatcher will always return a completed task,
188-
// when dealing with an IAsyncDisposable.
189-
// Therefore checking for a completed task and awaiting it
190-
// will only work on IDisposable
191-
Dispatcher.InvokeAsync(() =>
190+
returnTask = Dispatcher.InvokeAsync(async () =>
192191
{
193192
ResetUnhandledException();
194193

195194
foreach (var root in rootComponents)
196195
{
197196
root.Detach();
198197
}
198+
199+
await Task.WhenAll(disposalTasks).ConfigureAwait(false);
200+
disposalTasks.Clear();
199201
});
200202

201203
rootComponents.Clear();
202204
AssertNoUnhandledExceptions();
203205
}
206+
207+
return returnTask;
204208
}
205209

206210
/// <inheritdoc/>
@@ -212,6 +216,29 @@ protected override IComponent ResolveComponentForRenderMode(Type componentType,
212216
return componentActivator.CreateInstance(componentType);
213217
}
214218

219+
/// <inheritdoc/>
220+
protected override void AddPendingTask(ComponentState? componentState, Task task)
221+
{
222+
if (componentState is null)
223+
{
224+
ArgumentNullException.ThrowIfNull(task);
225+
AddDisposalTaskToQueue();
226+
}
227+
228+
base.AddPendingTask(componentState, task);
229+
230+
void AddDisposalTaskToQueue()
231+
{
232+
var t = task;
233+
t = task.ContinueWith(_ =>
234+
{
235+
disposalTasks.Remove(t);
236+
}, TaskScheduler.Current);
237+
238+
disposalTasks.Add(t);
239+
}
240+
}
241+
215242
internal Task SetDirectParametersAsync(RenderedFragment renderedComponent, ParameterView parameters)
216243
{
217244
ObjectDisposedException.ThrowIf(disposed, this);
@@ -412,6 +439,7 @@ protected override void Dispose(bool disposing)
412439
}
413440

414441
renderedComponents.Clear();
442+
disposalTasks.Clear();
415443
unhandledExceptionTsc.TrySetCanceled();
416444
}
417445

tests/bunit.testassets/AssertExtensions/TaskAssertionExtensions.cs

Lines changed: 0 additions & 9 deletions
This file was deleted.

tests/bunit.tests/BunitContextTest.cs

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,46 +6,46 @@ namespace Bunit;
66
public class BunitContextTest : BunitContext
77
{
88
[Fact(DisplayName = "DisposeComponents disposes rendered components in parent to child order")]
9-
public void Test101()
9+
public async Task Test101()
1010
{
1111
var callStack = new List<string>();
1212
Render<ParentDispose>(ps => ps.Add(p => p.CallStack, callStack));
1313

14-
DisposeComponents();
14+
await DisposeComponentsAsync();
1515

1616
callStack.Count.ShouldBe(2);
1717
callStack[0].ShouldBe("ParentDispose");
1818
callStack[1].ShouldBe("ChildDispose");
1919
}
2020

2121
[Fact(DisplayName = "DisposeComponents disposes multiple rendered components")]
22-
public void Test102()
22+
public async Task Test102()
2323
{
2424
var callStack = new List<string>();
2525
Render<ChildDispose>(ps => ps.Add(p => p.CallStack, callStack));
2626
Render<ChildDispose>(ps => ps.Add(p => p.CallStack, callStack));
2727

28-
DisposeComponents();
28+
await DisposeComponentsAsync();
2929

3030
callStack.Count.ShouldBe(2);
3131
}
3232

3333
[Fact(DisplayName = "DisposeComponents rethrows exceptions from Dispose methods in components")]
34-
public void Test103()
34+
public async Task Test103()
3535
{
3636
Render<ThrowExceptionComponent>();
37-
var action = () => DisposeComponents();
37+
Func<Task> action = () => DisposeComponentsAsync();
3838

39-
action.ShouldThrow<NotSupportedException>();
39+
await action.ShouldThrowAsync<NotSupportedException>();
4040
}
4141

4242
[Fact(DisplayName = "DisposeComponents disposes components nested in render fragments")]
43-
public void Test104()
43+
public async Task Test104()
4444
{
4545
var callStack = new List<string>();
4646
Render(DisposeFragments.ChildDisposeAsFragment(callStack));
4747

48-
DisposeComponents();
48+
await DisposeComponentsAsync();
4949

5050
callStack.Count.ShouldBe(1);
5151
}
@@ -171,16 +171,12 @@ public void Test0003()
171171
[Fact(DisplayName = "DisposeComponents captures exceptions from DisposeAsync in Renderer.UnhandledException")]
172172
public async Task Test201()
173173
{
174-
var tcs = new TaskCompletionSource();
175-
var expected = new NotSupportedException();
176-
Render<AsyncThrowExceptionComponent>(
177-
ps => ps.Add(p => p.DisposedTask, tcs.Task));
174+
Render<AsyncThrowAfterDelayComponent>();
178175

179-
DisposeComponents();
176+
await DisposeComponentsAsync();
180177

181-
tcs.SetException(expected);
182178
var actual = await Renderer.UnhandledException;
183-
actual.ShouldBeSameAs(expected);
179+
actual.ShouldBeAssignableTo<NotSupportedException>();
184180
}
185181

186182
[Fact(DisplayName = "DisposeComponents calls DisposeAsync on rendered components")]
@@ -189,19 +185,19 @@ public async Task Test202()
189185
var cut = Render<AsyncDisposableComponent>();
190186
var wasDisposedTask = cut.Instance.DisposedTask;
191187

192-
DisposeComponents();
188+
await DisposeComponentsAsync();
193189

194-
await wasDisposedTask.ShouldCompleteWithin(TimeSpan.FromSeconds(1));
190+
wasDisposedTask.Status.ShouldBe(TaskStatus.RanToCompletion);
195191
}
196192

197193
[Fact(DisplayName = "DisposeComponents should dispose components added via ComponentFactory")]
198-
public void Test203()
194+
public async Task Test203()
199195
{
200196
ComponentFactories.Add<ChildDispose, MyChildDisposeStub>();
201197
var cut = Render<ParentDispose>(ps => ps.Add(p => p.CallStack, new List<string>()));
202198
var instance = cut.FindComponent<MyChildDisposeStub>().Instance;
203199

204-
DisposeComponents();
200+
await DisposeComponentsAsync();
205201

206202
instance.WasDisposed.ShouldBeTrue();
207203
}
@@ -279,15 +275,13 @@ public void Dispose()
279275
WasDisposed = true;
280276
}
281277
}
282-
283-
private sealed class AsyncThrowExceptionComponent : ComponentBase, IAsyncDisposable
278+
279+
private sealed class AsyncThrowAfterDelayComponent : ComponentBase, IAsyncDisposable
284280
{
285-
[Parameter]
286-
public Task DisposedTask { get; set; }
287-
288281
public async ValueTask DisposeAsync()
289282
{
290-
await DisposedTask;
283+
await Task.Delay(1);
284+
throw new NotSupportedException();
291285
}
292286
}
293287

0 commit comments

Comments
 (0)