Skip to content

Commit 1ad90eb

Browse files
committed
Added async-disposable functionality to OwningComponentBase + tests
1 parent cb4eb4a commit 1ad90eb

File tree

3 files changed

+117
-6
lines changed

3 files changed

+117
-6
lines changed

src/Components/Components/src/OwningComponentBase.cs

Lines changed: 49 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,63 @@ protected IServiceProvider ScopedServices
4444
}
4545
}
4646

47+
/// <summary>
48+
/// Releases the service scope used by the component.
49+
/// </summary>
4750
void IDisposable.Dispose()
51+
{
52+
Dispose(disposing: true);
53+
GC.SuppressFinalize(this);
54+
}
55+
56+
/// <summary>
57+
/// Asynchronously releases the service scope used by the component.
58+
/// </summary>
59+
/// <returns>A task that represents the asynchronous dispose operation.</returns>
60+
public async ValueTask DisposeAsync()
61+
{
62+
await DisposeAsyncCore().ConfigureAwait(false);
63+
64+
Dispose(disposing: false);
65+
GC.SuppressFinalize(this);
66+
}
67+
68+
/// <summary>
69+
/// Releases the service scope used by the component.
70+
/// </summary>
71+
/// <param name="disposing">true to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
72+
protected virtual void Dispose(bool disposing)
4873
{
4974
if (!IsDisposed)
5075
{
51-
_scope?.Dispose();
52-
_scope = null;
53-
Dispose(disposing: true);
76+
if (disposing)
77+
{
78+
if (_scope.HasValue)
79+
{
80+
if (_scope.Value is IDisposable disposable)
81+
{
82+
disposable.Dispose();
83+
}
84+
_scope = null;
85+
}
86+
}
5487
IsDisposed = true;
5588
}
5689
}
5790

58-
/// <inheritdoc />
59-
protected virtual void Dispose(bool disposing)
91+
/// <summary>
92+
/// Asynchronously releases the service scope used by the component.
93+
/// </summary>
94+
/// <returns>A task that represents the asynchronous dispose operation.</returns>
95+
protected virtual async ValueTask DisposeAsyncCore()
6096
{
97+
if (!IsDisposed && _scope.HasValue)
98+
{
99+
await _scope.Value.DisposeAsync().ConfigureAwait(false);
100+
_scope = null;
101+
}
102+
103+
IsDisposed = true;
61104
}
62105
}
63106

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#nullable enable
22
*REMOVED*Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Components.ResourceAssetProperty!>? properties) -> void
3+
Microsoft.AspNetCore.Components.OwningComponentBase.DisposeAsync() -> System.Threading.Tasks.ValueTask
34
Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Components.ResourceAssetProperty!>? properties = null) -> void
45
Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.get -> System.Type!
56
Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.set -> void
@@ -20,4 +21,5 @@ static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponen
2021
static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
2122
static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
2223
static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
24+
virtual Microsoft.AspNetCore.Components.OwningComponentBase.DisposeAsyncCore() -> System.Threading.Tasks.ValueTask
2325
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)