diff --git a/src/Components/Components/src/OwningComponentBase.cs b/src/Components/Components/src/OwningComponentBase.cs index 739f0614258d..b2e9d9ef554a 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,53 @@ protected IServiceProvider ScopedServices } } + /// void IDisposable.Dispose() + { + Dispose(disposing: true); + 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 && _scope.HasValue && _scope.Value is IDisposable disposable) + { + disposable.Dispose(); + _scope = null; + } + IsDisposed = true; } } - /// - protected virtual void Dispose(bool disposing) + /// + async ValueTask IAsyncDisposable.DisposeAsync() { + await DisposeAsyncCore().ConfigureAwait(false); + + Dispose(disposing: false); + GC.SuppressFinalize(this); + } + + /// + /// 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 f11613946794..76ae63ac245a 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -19,5 +19,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! +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? 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; } }