diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 95903170d0..d111d01e1e 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -211,6 +211,7 @@ Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrameType.Text = 2 -> Micro Microsoft.AspNetCore.Components.RenderTree.Renderer Microsoft.AspNetCore.Components.RenderTree.Renderer.AssignRootComponentId(Microsoft.AspNetCore.Components.IComponent! component) -> int Microsoft.AspNetCore.Components.RenderTree.Renderer.Dispose() -> void +Microsoft.AspNetCore.Components.RenderTree.Renderer.DisposeAsync() -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Components.RenderTree.Renderer.ElementReferenceContext.get -> Microsoft.AspNetCore.Components.ElementReferenceContext? Microsoft.AspNetCore.Components.RenderTree.Renderer.ElementReferenceContext.set -> void Microsoft.AspNetCore.Components.RenderTree.Renderer.GetCurrentRenderTreeFrames(int componentId) -> Microsoft.AspNetCore.Components.RenderTree.ArrayRange diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index e61e46f65d..04fa1de8de 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -21,7 +21,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree // // Provides mechanisms for rendering hierarchies of instances, // dispatching events to them, and notifying when the user interface is being updated. - public abstract partial class Renderer : IDisposable + public abstract partial class Renderer : IDisposable, IAsyncDisposable { private readonly IServiceProvider _serviceProvider; private readonly Dictionary _componentStateById = new Dictionary(); @@ -36,6 +36,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree private ulong _lastEventHandlerId; private List _pendingTasks; private bool _disposed; + private Task _disposeTask; /// /// Allows the caller to handle exceptions from the SynchronizationContext when one is available. @@ -763,11 +764,34 @@ namespace Microsoft.AspNetCore.Components.RenderTree // It's important that we handle all exceptions here before reporting any of them. // This way we can dispose all components before an error handler kicks in. List exceptions = null; + List asyncDisposables = null; foreach (var componentState in _componentStateById.Values) { Log.DisposingComponent(_logger, componentState); - if (componentState.Component is IDisposable disposable) + // Components shouldn't need to implement IAsyncDisposable and IDisposable simultaneously, + // but in case they do, we prefer the async overload since we understand the sync overload + // is implemented for more "constrained" scenarios. + // Component authors are responsible for their IAsyncDisposable implementations not taking + // forever. + if (componentState.Component is IAsyncDisposable asyncDisposable) + { + try + { + var task = asyncDisposable.DisposeAsync(); + if (!task.IsCompletedSuccessfully) + { + asyncDisposables ??= new(); + asyncDisposables.Add(task.AsTask()); + } + } + catch (Exception exception) + { + exceptions ??= new List(); + exceptions.Add(exception); + } + } + else if (componentState.Component is IDisposable disposable) { try { @@ -784,13 +808,42 @@ namespace Microsoft.AspNetCore.Components.RenderTree _componentStateById.Clear(); // So we know they were all disposed _batchBuilder.Dispose(); - if (exceptions?.Count > 1) + NotifyExceptions(exceptions); + + if (asyncDisposables?.Count >= 1) { - HandleException(new AggregateException("Exceptions were encountered while disposing components.", exceptions)); + _disposeTask = HandleAsyncExceptions(asyncDisposables); } - else if (exceptions?.Count == 1) + + async Task HandleAsyncExceptions(List tasks) { - HandleException(exceptions[0]); + List asyncExceptions = null; + foreach (var task in tasks) + { + try + { + await task; + } + catch (Exception exception) + { + asyncExceptions ??= new List(); + asyncExceptions.Add(exception); + } + } + + NotifyExceptions(asyncExceptions); + } + + void NotifyExceptions(List exceptions) + { + if (exceptions?.Count > 1) + { + HandleException(new AggregateException("Exceptions were encountered while disposing components.", exceptions)); + } + else if (exceptions?.Count == 1) + { + HandleException(exceptions[0]); + } } } @@ -801,5 +854,31 @@ namespace Microsoft.AspNetCore.Components.RenderTree { Dispose(disposing: true); } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + if (_disposeTask != null) + { + await _disposeTask; + } + else + { + Dispose(); + if (_disposeTask != null) + { + await _disposeTask; + } + else + { + await default(ValueTask); + } + } + } } } diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 249e1df9ee..efa3db8991 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -3813,6 +3813,66 @@ namespace Microsoft.AspNetCore.Components.Test Assert.Contains(exception2, aex.InnerExceptions); } + [Fact] + public async Task DisposingRenderer_CapturesSyncExceptionsFromAllRegisteredAsyncDisposableComponents() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var exception1 = new InvalidOperationException(); + var disposed = false; + + var component = new TestComponent(builder => + { + builder.AddContent(0, "Hello"); + builder.OpenComponent(1); + builder.AddAttribute(1, nameof(AsyncDisposableComponent.AsyncDisposeAction), (Func)(() => { disposed = true; throw exception1; })); + builder.CloseComponent(); + }); + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Act + await renderer.DisposeAsync(); + + // Assert + Assert.True(disposed); + var handledException = Assert.Single(renderer.HandledExceptions); + Assert.Same(exception1, handledException); + } + + [Fact] + public async Task DisposingRenderer_CapturesAsyncExceptionsFromAllRegisteredAsyncDisposableComponents() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var exception1 = new InvalidOperationException(); + var disposed = false; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var component = new TestComponent(builder => + { + builder.AddContent(0, "Hello"); + builder.OpenComponent(1); + builder.AddAttribute(1, nameof(AsyncDisposableComponent.AsyncDisposeAction), (Func)(async () => { await tcs.Task; disposed = true; throw exception1; })); + builder.CloseComponent(); + }); + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Act + var disposal = renderer.DisposeAsync(); + Assert.False(disposed); + Assert.False(disposal.IsCompleted); + + tcs.TrySetResult(); + await disposal; + + // Assert + Assert.True(disposed); + var handledException = Assert.Single(renderer.HandledExceptions); + Assert.Same(exception1, handledException); + } + [Theory] [InlineData(null)] // No existing attribute to update [InlineData("old property value")] // Has existing attribute to update diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 0c43ce2699..f3b34f4f57 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -168,7 +168,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits try { - Renderer.Dispose(); + await Renderer.DisposeAsync(); // This cast is needed because it's possible the scope may not support async dispose. // Our DI container does, but other DI systems may not. diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index 12865430dc..3649f6b8b7 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -74,7 +74,10 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting _disposed = true; - _renderer?.Dispose(); + if (_renderer != null) + { + await _renderer.DisposeAsync(); + } if (_scope is IAsyncDisposable asyncDisposableScope) {