diff --git a/src/Components/Components/src/OwningComponentBase.cs b/src/Components/Components/src/OwningComponentBase.cs index b2e9d9ef554a..a60f396e89ec 100644 --- a/src/Components/Components/src/OwningComponentBase.cs +++ b/src/Components/Components/src/OwningComponentBase.cs @@ -44,7 +44,7 @@ protected IServiceProvider ScopedServices } } - /// + /// void IDisposable.Dispose() { Dispose(disposing: true); @@ -69,12 +69,20 @@ protected virtual void Dispose(bool disposing) } } - /// + /// async ValueTask IAsyncDisposable.DisposeAsync() { - await DisposeAsyncCore().ConfigureAwait(false); - - Dispose(disposing: false); + if (!IsDisposed) + { + try + { + await DisposeAsyncCore().ConfigureAwait(false); + } + finally + { + Dispose(disposing: true); + } + } GC.SuppressFinalize(this); } @@ -89,8 +97,6 @@ protected virtual async ValueTask DisposeAsyncCore() await _scope.Value.DisposeAsync().ConfigureAwait(false); _scope = null; } - - IsDisposed = true; } } diff --git a/src/Components/Components/test/OwningComponentBaseTest.cs b/src/Components/Components/test/OwningComponentBaseTest.cs index ae9a2b4a67ac..8064f75cb2c3 100644 --- a/src/Components/Components/test/OwningComponentBaseTest.cs +++ b/src/Components/Components/test/OwningComponentBaseTest.cs @@ -111,6 +111,98 @@ public MyService(Counter counter) void IDisposable.Dispose() => Counter.DisposedCount++; } + [Fact] + public async Task DisposeAsync_CallsDispose_WithDisposingTrue() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddTransient(); + var serviceProvider = services.BuildServiceProvider(); + + var renderer = new TestRenderer(serviceProvider); + var component = (ComponentWithDispose)renderer.InstantiateComponent(); + + _ = component.MyService; + await ((IAsyncDisposable)component).DisposeAsync(); + Assert.True(component.DisposingParameter); + } + + [Fact] + public async Task DisposeAsync_ThenDispose_IsIdempotent() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddTransient(); + var serviceProvider = services.BuildServiceProvider(); + + var counter = serviceProvider.GetRequiredService(); + var renderer = new TestRenderer(serviceProvider); + var component = (ComponentWithDispose)renderer.InstantiateComponent(); + + _ = component.MyService; + + await ((IAsyncDisposable)component).DisposeAsync(); + var firstCallCount = component.DisposeCallCount; + Assert.Equal(1, counter.DisposedCount); + + ((IDisposable)component).Dispose(); + Assert.True(component.DisposeCallCount >= firstCallCount); + Assert.Equal(1, counter.DisposedCount); + } + + [Fact] + public async Task DisposeAsyncCore_Override_WithException_StillCallsDispose() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddTransient(); + var serviceProvider = services.BuildServiceProvider(); + + var renderer = new TestRenderer(serviceProvider); + var component = (ComponentWithThrowingDisposeAsyncCore)renderer.InstantiateComponent(); + + _ = component.MyService; + + await Assert.ThrowsAsync(async () => + await ((IAsyncDisposable)component).DisposeAsync()); + + Assert.True(component.DisposingParameter); + Assert.True(component.IsDisposedPublic); + } + + private class ComponentWithDispose : OwningComponentBase + { + public MyService MyService => Service; + public bool? DisposingParameter { get; private set; } + public int DisposeCallCount { get; private set; } + + protected override void Dispose(bool disposing) + { + DisposingParameter = disposing; + DisposeCallCount++; + base.Dispose(disposing); + } + } + + private class ComponentWithThrowingDisposeAsyncCore : OwningComponentBase + { + public MyService MyService => Service; + public bool? DisposingParameter { get; private set; } + public bool IsDisposedPublic => IsDisposed; + + protected override async ValueTask DisposeAsyncCore() + { + await base.DisposeAsyncCore(); + throw new InvalidOperationException("Something went wrong in async disposal"); + } + + protected override void Dispose(bool disposing) + { + DisposingParameter = disposing; + base.Dispose(disposing); + } + } + private class MyOwningComponent : OwningComponentBase { public MyService MyService => Service; @@ -118,4 +210,69 @@ private class MyOwningComponent : OwningComponentBase // Expose IsDisposed for testing public bool IsDisposedPublic => IsDisposed; } + + [Fact] + public async Task ComplexComponent_DisposesResourcesOnlyWhenDisposingIsTrue() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddTransient(); + var serviceProvider = services.BuildServiceProvider(); + + var renderer = new TestRenderer(serviceProvider); + var component = (ComplexComponent)renderer.InstantiateComponent(); + + _ = component.MyService; + + await ((IAsyncDisposable)component).DisposeAsync(); + + // Verify all managed resources were disposed because disposing=true + Assert.True(component.TimerDisposed); + Assert.True(component.CancellationTokenSourceDisposed); + Assert.True(component.EventUnsubscribed); + Assert.Equal(1, component.ManagedResourcesCleanedUpCount); + } + + private class ComplexComponent : OwningComponentBase + { + private readonly System.Threading.Timer _timer; + private readonly CancellationTokenSource _cts; + private bool _eventSubscribed; + + public MyService MyService => Service; + public bool TimerDisposed { get; private set; } + public bool CancellationTokenSourceDisposed { get; private set; } + public bool EventUnsubscribed { get; private set; } + public int ManagedResourcesCleanedUpCount { get; private set; } + + public ComplexComponent() + { + _timer = new System.Threading.Timer(_ => { }, null, Timeout.Infinite, Timeout.Infinite); + _cts = new CancellationTokenSource(); + _eventSubscribed = true; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _timer?.Dispose(); + TimerDisposed = true; + + _cts?.Cancel(); + _cts?.Dispose(); + CancellationTokenSourceDisposed = true; + + if (_eventSubscribed) + { + EventUnsubscribed = true; + _eventSubscribed = false; + } + + ManagedResourcesCleanedUpCount++; + } + + base.Dispose(disposing); + } + } }