From 1ad90eb284a5abd7c9d381d3b711965f455b0bf8 Mon Sep 17 00:00:00 2001 From: Roland Vizner Date: Mon, 7 Jul 2025 13:04:16 +0200 Subject: [PATCH 1/5] Added async-disposable functionality to OwningComponentBase + tests --- .../Components/src/OwningComponentBase.cs | 55 ++++++++++++++-- .../Components/src/PublicAPI.Unshipped.txt | 2 + .../test/OwningComponentBaseTest.cs | 66 +++++++++++++++++++ 3 files changed, 117 insertions(+), 6 deletions(-) diff --git a/src/Components/Components/src/OwningComponentBase.cs b/src/Components/Components/src/OwningComponentBase.cs index 739f0614258d..972fe0eeb8ed 100644 --- a/src/Components/Components/src/OwningComponentBase.cs +++ b/src/Components/Components/src/OwningComponentBase.cs @@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Components; /// requires disposal such as a repository or database abstraction. Using /// as a base class ensures that the service provider scope is disposed with the component. /// -public abstract class OwningComponentBase : ComponentBase, IDisposable +public abstract class OwningComponentBase : ComponentBase, IDisposable, IAsyncDisposable { private AsyncServiceScope? _scope; @@ -44,20 +44,63 @@ protected IServiceProvider ScopedServices } } + /// + /// Releases the service scope used by the component. + /// void IDisposable.Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Asynchronously releases the service scope used by the component. + /// + /// A task that represents the asynchronous dispose operation. + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + + Dispose(disposing: false); + GC.SuppressFinalize(this); + } + + /// + /// Releases the service scope used by the component. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) { if (!IsDisposed) { - _scope?.Dispose(); - _scope = null; - Dispose(disposing: true); + if (disposing) + { + if (_scope.HasValue) + { + if (_scope.Value is IDisposable disposable) + { + disposable.Dispose(); + } + _scope = null; + } + } IsDisposed = true; } } - /// - protected virtual void Dispose(bool disposing) + /// + /// Asynchronously releases the service scope used by the component. + /// + /// A task that represents the asynchronous dispose operation. + protected virtual async ValueTask DisposeAsyncCore() { + if (!IsDisposed && _scope.HasValue) + { + await _scope.Value.DisposeAsync().ConfigureAwait(false); + _scope = null; + } + + IsDisposed = true; } } diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 33bf7c236923..6d7a6d12bc7d 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,5 +1,6 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList? properties) -> void +Microsoft.AspNetCore.Components.OwningComponentBase.DisposeAsync() -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList? properties = null) -> void Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.get -> System.Type! Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.set -> void @@ -20,4 +21,5 @@ static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponen static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +virtual Microsoft.AspNetCore.Components.OwningComponentBase.DisposeAsyncCore() -> System.Threading.Tasks.ValueTask virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.GetComponentKey() -> object? diff --git a/src/Components/Components/test/OwningComponentBaseTest.cs b/src/Components/Components/test/OwningComponentBaseTest.cs index 7bd68ad750c7..ae9a2b4a67ac 100644 --- a/src/Components/Components/test/OwningComponentBaseTest.cs +++ b/src/Components/Components/test/OwningComponentBaseTest.cs @@ -29,6 +29,69 @@ public void CreatesScopeAndService() Assert.Equal(1, counter.DisposedCount); } + [Fact] + public async Task DisposeAsyncReleasesScopeAndService() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddTransient(); + var serviceProvider = services.BuildServiceProvider(); + + var counter = serviceProvider.GetRequiredService(); + var renderer = new TestRenderer(serviceProvider); + var component1 = (MyOwningComponent)renderer.InstantiateComponent(); + + Assert.NotNull(component1.MyService); + Assert.Equal(1, counter.CreatedCount); + Assert.Equal(0, counter.DisposedCount); + Assert.False(component1.IsDisposedPublic); + + await ((IAsyncDisposable)component1).DisposeAsync(); + Assert.Equal(1, counter.CreatedCount); + Assert.Equal(1, counter.DisposedCount); + Assert.True(component1.IsDisposedPublic); + } + + [Fact] + public void ThrowsWhenAccessingScopedServicesAfterDispose() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddTransient(); + var serviceProvider = services.BuildServiceProvider(); + + var renderer = new TestRenderer(serviceProvider); + var component1 = (MyOwningComponent)renderer.InstantiateComponent(); + + // Access service first to create scope + var service = component1.MyService; + + ((IDisposable)component1).Dispose(); + + // Should throw when trying to access services after disposal + Assert.Throws(() => component1.MyService); + } + + [Fact] + public async Task ThrowsWhenAccessingScopedServicesAfterDisposeAsync() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddTransient(); + var serviceProvider = services.BuildServiceProvider(); + + var renderer = new TestRenderer(serviceProvider); + var component1 = (MyOwningComponent)renderer.InstantiateComponent(); + + // Access service first to create scope + var service = component1.MyService; + + await ((IAsyncDisposable)component1).DisposeAsync(); + + // Should throw when trying to access services after disposal + Assert.Throws(() => component1.MyService); + } + private class Counter { public int CreatedCount { get; set; } @@ -51,5 +114,8 @@ public MyService(Counter counter) private class MyOwningComponent : OwningComponentBase { public MyService MyService => Service; + + // Expose IsDisposed for testing + public bool IsDisposedPublic => IsDisposed; } } From b6b3dddd0b2dd6cd2ede231407efac828175275b Mon Sep 17 00:00:00 2001 From: Roland Vizner <148648143+rolandVi@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:25:08 +0200 Subject: [PATCH 2/5] Update PublicAPI.Unshipped.txt Reverted one change --- src/Components/Components/src/PublicAPI.Unshipped.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index b16bee061ca6..c11da0699556 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -20,7 +20,6 @@ Microsoft.AspNetCore.Components.Infrastructure.PersistentStateProviderServiceCol static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! virtual Microsoft.AspNetCore.Components.OwningComponentBase.DisposeAsyncCore() -> System.Threading.Tasks.ValueTask static Microsoft.AspNetCore.Components.Infrastructure.PersistentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.GetComponentKey() -> object? From 5bbf6b21dbcb1f505c332739216bb4ef6098888a Mon Sep 17 00:00:00 2001 From: Roland Vizner Date: Mon, 7 Jul 2025 14:54:39 +0200 Subject: [PATCH 3/5] DisposeAsync implementation made private + style restructure --- .../Components/src/OwningComponentBase.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Components/Components/src/OwningComponentBase.cs b/src/Components/Components/src/OwningComponentBase.cs index 972fe0eeb8ed..ea32df2ce456 100644 --- a/src/Components/Components/src/OwningComponentBase.cs +++ b/src/Components/Components/src/OwningComponentBase.cs @@ -53,18 +53,6 @@ void IDisposable.Dispose() GC.SuppressFinalize(this); } - /// - /// Asynchronously releases the service scope used by the component. - /// - /// A task that represents the asynchronous dispose operation. - public async ValueTask DisposeAsync() - { - await DisposeAsyncCore().ConfigureAwait(false); - - Dispose(disposing: false); - GC.SuppressFinalize(this); - } - /// /// Releases the service scope used by the component. /// @@ -88,6 +76,18 @@ protected virtual void Dispose(bool disposing) } } + /// + /// Asynchronously releases the service scope used by the component. + /// + /// A task that represents the asynchronous dispose operation. + async ValueTask IAsyncDisposable.DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + + Dispose(disposing: false); + GC.SuppressFinalize(this); + } + /// /// Asynchronously releases the service scope used by the component. /// From 99ff2c67100167728fe81693a466c1ea62a36ee8 Mon Sep 17 00:00:00 2001 From: Roland Vizner <148648143+rolandVi@users.noreply.github.com> Date: Mon, 7 Jul 2025 16:34:46 +0200 Subject: [PATCH 4/5] Update PublicAPI.Unshipped.txt Removed `OwningComponentBase.DisposeAsync()` from the PublicAPI as the implementation is private now --- src/Components/Components/src/PublicAPI.Unshipped.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index c11da0699556..76ae63ac245a 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,6 +1,5 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList? properties) -> void -Microsoft.AspNetCore.Components.OwningComponentBase.DisposeAsync() -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList? properties = null) -> void Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.get -> System.Type! Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.set -> void From 085b85f5f6c8b8aef7a2d3b86168d7376ead7820 Mon Sep 17 00:00:00 2001 From: Roland Vizner Date: Tue, 8 Jul 2025 09:52:40 +0200 Subject: [PATCH 5/5] Doc + style changes --- .../Components/src/OwningComponentBase.cs | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/Components/Components/src/OwningComponentBase.cs b/src/Components/Components/src/OwningComponentBase.cs index ea32df2ce456..b2e9d9ef554a 100644 --- a/src/Components/Components/src/OwningComponentBase.cs +++ b/src/Components/Components/src/OwningComponentBase.cs @@ -44,9 +44,7 @@ protected IServiceProvider ScopedServices } } - /// - /// Releases the service scope used by the component. - /// + /// void IDisposable.Dispose() { Dispose(disposing: true); @@ -61,25 +59,17 @@ protected virtual void Dispose(bool disposing) { if (!IsDisposed) { - if (disposing) + if (disposing && _scope.HasValue && _scope.Value is IDisposable disposable) { - if (_scope.HasValue) - { - if (_scope.Value is IDisposable disposable) - { - disposable.Dispose(); - } - _scope = null; - } + disposable.Dispose(); + _scope = null; } + IsDisposed = true; } } - /// - /// Asynchronously releases the service scope used by the component. - /// - /// A task that represents the asynchronous dispose operation. + /// async ValueTask IAsyncDisposable.DisposeAsync() { await DisposeAsyncCore().ConfigureAwait(false);