Skip to content

Commit 82d75a0

Browse files
authored
OwningComponentBase implements IAsyncDisposable (#62583)
* Add async-disposable functionality to OwningComponentBase * Add unit tests * Update public API documentation --------- Co-authored-by: Roland Vizner <t-rvizner@microsoft.com>
1 parent 8883b98 commit 82d75a0

File tree

3 files changed

+106
-6
lines changed

3 files changed

+106
-6
lines changed

src/Components/Components/src/OwningComponentBase.cs

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Components;
1414
/// requires disposal such as a repository or database abstraction. Using <see cref="OwningComponentBase"/>
1515
/// as a base class ensures that the service provider scope is disposed with the component.
1616
/// </remarks>
17-
public abstract class OwningComponentBase : ComponentBase, IDisposable
17+
public abstract class OwningComponentBase : ComponentBase, IDisposable, IAsyncDisposable
1818
{
1919
private AsyncServiceScope? _scope;
2020

@@ -44,20 +44,53 @@ protected IServiceProvider ScopedServices
4444
}
4545
}
4646

47+
/// <inhertidoc />
4748
void IDisposable.Dispose()
49+
{
50+
Dispose(disposing: true);
51+
GC.SuppressFinalize(this);
52+
}
53+
54+
/// <summary>
55+
/// Releases the service scope used by the component.
56+
/// </summary>
57+
/// <param name="disposing">true to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
58+
protected virtual void Dispose(bool disposing)
4859
{
4960
if (!IsDisposed)
5061
{
51-
_scope?.Dispose();
52-
_scope = null;
53-
Dispose(disposing: true);
62+
if (disposing && _scope.HasValue && _scope.Value is IDisposable disposable)
63+
{
64+
disposable.Dispose();
65+
_scope = null;
66+
}
67+
5468
IsDisposed = true;
5569
}
5670
}
5771

58-
/// <inheritdoc />
59-
protected virtual void Dispose(bool disposing)
72+
/// <inhertidoc />
73+
async ValueTask IAsyncDisposable.DisposeAsync()
6074
{
75+
await DisposeAsyncCore().ConfigureAwait(false);
76+
77+
Dispose(disposing: false);
78+
GC.SuppressFinalize(this);
79+
}
80+
81+
/// <summary>
82+
/// Asynchronously releases the service scope used by the component.
83+
/// </summary>
84+
/// <returns>A task that represents the asynchronous dispose operation.</returns>
85+
protected virtual async ValueTask DisposeAsyncCore()
86+
{
87+
if (!IsDisposed && _scope.HasValue)
88+
{
89+
await _scope.Value.DisposeAsync().ConfigureAwait(false);
90+
_scope = null;
91+
}
92+
93+
IsDisposed = true;
6194
}
6295
}
6396

src/Components/Components/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,6 @@ abstract Microsoft.AspNetCore.Components.PersistentComponentStateSerializer<T>.R
2323
static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration<TService>(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
2424
static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
2525
static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
26+
virtual Microsoft.AspNetCore.Components.OwningComponentBase.DisposeAsyncCore() -> System.Threading.Tasks.ValueTask
2627
static Microsoft.AspNetCore.Components.Infrastructure.PersistentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
2728
virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.GetComponentKey() -> object?

src/Components/Components/test/OwningComponentBaseTest.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,69 @@ public void CreatesScopeAndService()
2929
Assert.Equal(1, counter.DisposedCount);
3030
}
3131

32+
[Fact]
33+
public async Task DisposeAsyncReleasesScopeAndService()
34+
{
35+
var services = new ServiceCollection();
36+
services.AddSingleton<Counter>();
37+
services.AddTransient<MyService>();
38+
var serviceProvider = services.BuildServiceProvider();
39+
40+
var counter = serviceProvider.GetRequiredService<Counter>();
41+
var renderer = new TestRenderer(serviceProvider);
42+
var component1 = (MyOwningComponent)renderer.InstantiateComponent<MyOwningComponent>();
43+
44+
Assert.NotNull(component1.MyService);
45+
Assert.Equal(1, counter.CreatedCount);
46+
Assert.Equal(0, counter.DisposedCount);
47+
Assert.False(component1.IsDisposedPublic);
48+
49+
await ((IAsyncDisposable)component1).DisposeAsync();
50+
Assert.Equal(1, counter.CreatedCount);
51+
Assert.Equal(1, counter.DisposedCount);
52+
Assert.True(component1.IsDisposedPublic);
53+
}
54+
55+
[Fact]
56+
public void ThrowsWhenAccessingScopedServicesAfterDispose()
57+
{
58+
var services = new ServiceCollection();
59+
services.AddSingleton<Counter>();
60+
services.AddTransient<MyService>();
61+
var serviceProvider = services.BuildServiceProvider();
62+
63+
var renderer = new TestRenderer(serviceProvider);
64+
var component1 = (MyOwningComponent)renderer.InstantiateComponent<MyOwningComponent>();
65+
66+
// Access service first to create scope
67+
var service = component1.MyService;
68+
69+
((IDisposable)component1).Dispose();
70+
71+
// Should throw when trying to access services after disposal
72+
Assert.Throws<ObjectDisposedException>(() => component1.MyService);
73+
}
74+
75+
[Fact]
76+
public async Task ThrowsWhenAccessingScopedServicesAfterDisposeAsync()
77+
{
78+
var services = new ServiceCollection();
79+
services.AddSingleton<Counter>();
80+
services.AddTransient<MyService>();
81+
var serviceProvider = services.BuildServiceProvider();
82+
83+
var renderer = new TestRenderer(serviceProvider);
84+
var component1 = (MyOwningComponent)renderer.InstantiateComponent<MyOwningComponent>();
85+
86+
// Access service first to create scope
87+
var service = component1.MyService;
88+
89+
await ((IAsyncDisposable)component1).DisposeAsync();
90+
91+
// Should throw when trying to access services after disposal
92+
Assert.Throws<ObjectDisposedException>(() => component1.MyService);
93+
}
94+
3295
private class Counter
3396
{
3497
public int CreatedCount { get; set; }
@@ -51,5 +114,8 @@ public MyService(Counter counter)
51114
private class MyOwningComponent : OwningComponentBase<MyService>
52115
{
53116
public MyService MyService => Service;
117+
118+
// Expose IsDisposed for testing
119+
public bool IsDisposedPublic => IsDisposed;
54120
}
55121
}

0 commit comments

Comments
 (0)