diff --git a/AspNetCore.sln b/AspNetCore.sln index ca9dfcf28181..aea9643f478f 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1427,6 +1427,20 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Compon EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.Web.Extensions.Tests", "src\Components\Web.Extensions\test\Microsoft.AspNetCore.Components.Web.Extensions.Tests.csproj", "{157605CB-5170-4C1A-980F-4BAE42DB60DE}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "testassets", "testassets", "{6126DCE4-9692-4EE2-B240-C65743572995}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicTestApp", "src\Components\test\testassets\BasicTestApp\BasicTestApp.csproj", "{46FB7E93-1294-4068-B80A-D4864F78277A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComponentsApp.App", "src\Components\test\testassets\ComponentsApp.App\ComponentsApp.App.csproj", "{25FA84DB-EEA7-4068-8E2D-F3D48B281C16}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComponentsApp.Server", "src\Components\test\testassets\ComponentsApp.Server\ComponentsApp.Server.csproj", "{19974360-4A63-425A-94DB-C2C940A3A97A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LazyTestContentPackage", "src\Components\test\testassets\LazyTestContentPackage\LazyTestContentPackage.csproj", "{ADF9C126-F322-4E34-AFD3-E626A4487206}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestContentPackage", "src\Components\test\testassets\TestContentPackage\TestContentPackage.csproj", "{3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components.TestServer", "src\Components\test\testassets\TestServer\Components.TestServer.csproj", "{8A59AF88-4A82-46ED-977D-D909001F8107}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -6729,6 +6743,78 @@ Global {157605CB-5170-4C1A-980F-4BAE42DB60DE}.Release|x64.Build.0 = Release|Any CPU {157605CB-5170-4C1A-980F-4BAE42DB60DE}.Release|x86.ActiveCfg = Release|Any CPU {157605CB-5170-4C1A-980F-4BAE42DB60DE}.Release|x86.Build.0 = Release|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Debug|x64.ActiveCfg = Debug|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Debug|x64.Build.0 = Debug|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Debug|x86.ActiveCfg = Debug|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Debug|x86.Build.0 = Debug|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Release|Any CPU.Build.0 = Release|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Release|x64.ActiveCfg = Release|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Release|x64.Build.0 = Release|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Release|x86.ActiveCfg = Release|Any CPU + {46FB7E93-1294-4068-B80A-D4864F78277A}.Release|x86.Build.0 = Release|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Debug|x64.ActiveCfg = Debug|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Debug|x64.Build.0 = Debug|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Debug|x86.ActiveCfg = Debug|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Debug|x86.Build.0 = Debug|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Release|Any CPU.Build.0 = Release|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Release|x64.ActiveCfg = Release|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Release|x64.Build.0 = Release|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Release|x86.ActiveCfg = Release|Any CPU + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16}.Release|x86.Build.0 = Release|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Debug|x64.ActiveCfg = Debug|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Debug|x64.Build.0 = Debug|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Debug|x86.ActiveCfg = Debug|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Debug|x86.Build.0 = Debug|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Release|Any CPU.Build.0 = Release|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Release|x64.ActiveCfg = Release|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Release|x64.Build.0 = Release|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Release|x86.ActiveCfg = Release|Any CPU + {19974360-4A63-425A-94DB-C2C940A3A97A}.Release|x86.Build.0 = Release|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Debug|x64.ActiveCfg = Debug|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Debug|x64.Build.0 = Debug|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Debug|x86.ActiveCfg = Debug|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Debug|x86.Build.0 = Debug|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Release|Any CPU.Build.0 = Release|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Release|x64.ActiveCfg = Release|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Release|x64.Build.0 = Release|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Release|x86.ActiveCfg = Release|Any CPU + {ADF9C126-F322-4E34-AFD3-E626A4487206}.Release|x86.Build.0 = Release|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Debug|x64.ActiveCfg = Debug|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Debug|x64.Build.0 = Debug|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Debug|x86.ActiveCfg = Debug|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Debug|x86.Build.0 = Debug|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Release|Any CPU.Build.0 = Release|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Release|x64.ActiveCfg = Release|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Release|x64.Build.0 = Release|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Release|x86.ActiveCfg = Release|Any CPU + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}.Release|x86.Build.0 = Release|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Debug|x64.Build.0 = Debug|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Debug|x86.Build.0 = Debug|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Release|Any CPU.Build.0 = Release|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Release|x64.ActiveCfg = Release|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Release|x64.Build.0 = Release|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Release|x86.ActiveCfg = Release|Any CPU + {8A59AF88-4A82-46ED-977D-D909001F8107}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -7444,6 +7530,13 @@ Global {F71FE795-9923-461B-9809-BB1821A276D0} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF} {8294A74F-7DAA-4B69-BC56-7634D93C9693} = {F71FE795-9923-461B-9809-BB1821A276D0} {157605CB-5170-4C1A-980F-4BAE42DB60DE} = {F71FE795-9923-461B-9809-BB1821A276D0} + {6126DCE4-9692-4EE2-B240-C65743572995} = {0508E463-0269-40C9-B5C2-3B600FB2A28B} + {46FB7E93-1294-4068-B80A-D4864F78277A} = {6126DCE4-9692-4EE2-B240-C65743572995} + {25FA84DB-EEA7-4068-8E2D-F3D48B281C16} = {6126DCE4-9692-4EE2-B240-C65743572995} + {19974360-4A63-425A-94DB-C2C940A3A97A} = {6126DCE4-9692-4EE2-B240-C65743572995} + {ADF9C126-F322-4E34-AFD3-E626A4487206} = {6126DCE4-9692-4EE2-B240-C65743572995} + {3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31} = {6126DCE4-9692-4EE2-B240-C65743572995} + {8A59AF88-4A82-46ED-977D-D909001F8107} = {6126DCE4-9692-4EE2-B240-C65743572995} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 446243201ff9..e284e0c867e4 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -498,6 +498,7 @@ private void ProcessRenderQueue() { ProcessRenderQueue(); } + ComponentsProfiling.Instance.End(); } @@ -634,11 +635,43 @@ private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry) var disposeComponentId = _batchBuilder.ComponentDisposalQueue.Dequeue(); var disposeComponentState = GetRequiredComponentState(disposeComponentId); Log.DisposingComponent(_logger, disposeComponentState); - if (!disposeComponentState.TryDisposeInBatch(_batchBuilder, out var exception)) + if (!(disposeComponentState.Component is IAsyncDisposable)) + { + if (!disposeComponentState.TryDisposeInBatch(_batchBuilder, out var exception)) + { + exceptions ??= new List(); + exceptions.Add(exception); + } + } + else { - exceptions ??= new List(); - exceptions.Add(exception); + var result = disposeComponentState.DisposeInBatchAsync(_batchBuilder); + if (result.IsCompleted) + { + if (!result.IsCompletedSuccessfully) + { + exceptions ??= new List(); + exceptions.Add(result.Exception); + } + } + else + { + AddToPendingTasks(GetHandledAsynchronousDisposalErrorsTask(result)); + + async Task GetHandledAsynchronousDisposalErrorsTask(Task result) + { + try + { + await result; + } + catch (Exception e) + { + HandleException(e); + } + } + } } + _componentStateById.Remove(disposeComponentId); _batchBuilder.DisposedComponentIds.Append(disposeComponentId); } diff --git a/src/Components/Components/src/Rendering/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs index 7b755efd5dfc..760d3b8d1a4b 100644 --- a/src/Components/Components/src/Rendering/ComponentState.cs +++ b/src/Components/Components/src/Rendering/ComponentState.cs @@ -101,6 +101,13 @@ public bool TryDisposeInBatch(RenderBatchBuilder batchBuilder, [NotNullWhen(fals exception = ex; } + CleanupComponentStateResources(batchBuilder); + + return exception == null; + } + + private void CleanupComponentStateResources(RenderBatchBuilder batchBuilder) + { // We don't expect these things to throw. RenderTreeDiffBuilder.DisposeFrames(batchBuilder, CurrentRenderTree.GetFrames()); @@ -110,8 +117,6 @@ public bool TryDisposeInBatch(RenderBatchBuilder batchBuilder, [NotNullWhen(fals } DisposeBuffers(); - - return exception == null; } // Callers expect this method to always return a faulted task. @@ -222,5 +227,31 @@ private void DisposeBuffers() ((IDisposable)CurrentRenderTree).Dispose(); _latestDirectParametersSnapshot?.Dispose(); } + + public Task DisposeInBatchAsync(RenderBatchBuilder batchBuilder) + { + _componentWasDisposed = true; + + CleanupComponentStateResources(batchBuilder); + + try + { + var result = ((IAsyncDisposable)Component).DisposeAsync(); + if (result.IsCompletedSuccessfully) + { + return Task.CompletedTask; + } + else + { + // We know we are dealing with an exception that happened asynchronously, so return a task + // to the caller so that he can unwrap it. + return result.AsTask(); + } + } + catch (Exception e) + { + return Task.FromException(e); + } + } } } diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 07022b2edff6..fa47f7284286 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -487,7 +487,8 @@ public void CanDispatchEventsToTopLevelComponents() public void DispatchEventHandlesSynchronousExceptionsFromEventHandlers() { // Arrange: Render a component with an event handler - var renderer = new TestRenderer { + var renderer = new TestRenderer + { ShouldHandleExceptions = true }; @@ -2086,6 +2087,238 @@ public void RenderBatch_HandlesExceptionsFromAllDisposedComponents() Assert.Contains(exception2, aex.InnerExceptions); } + [Fact] + public void RenderBatch_HandlesSynchronousExceptionsInAsyncDisposableComponents() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var exception1 = new InvalidOperationException(); + + var firstRender = true; + var component = new TestComponent(builder => + { + if (firstRender) + { + builder.AddContent(0, "Hello"); + builder.OpenComponent(1); + builder.AddAttribute(1, nameof(AsyncDisposableComponent.AsyncDisposeAction), (Func)(() => throw exception1)); + builder.CloseComponent(); + } + }); + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Act: Second render + firstRender = false; + component.TriggerRender(); + + // Assert: Applicable children are included in disposal list + Assert.Equal(2, renderer.Batches.Count); + Assert.Equal(new[] { 1, }, renderer.Batches[1].DisposedComponentIDs); + + // Outer component is still alive and not disposed. + Assert.False(component.Disposed); + var aex = Assert.IsType(Assert.Single(renderer.HandledExceptions)); + var innerException = Assert.Single(aex.Flatten().InnerExceptions); + Assert.Same(exception1, innerException); + } + + [Fact] + public void RenderBatch_CanDisposeSynchrounousAsyncDisposableImplementations() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + + var firstRender = true; + var component = new TestComponent(builder => + { + if (firstRender) + { + builder.AddContent(0, "Hello"); + builder.OpenComponent(1); + builder.AddAttribute(1, nameof(AsyncDisposableComponent.AsyncDisposeAction), (Func)(() => default)); + builder.CloseComponent(); + } + }); + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Act: Second render + firstRender = false; + component.TriggerRender(); + + // Assert: Applicable children are included in disposal list + Assert.Equal(2, renderer.Batches.Count); + Assert.Equal(new[] { 1, }, renderer.Batches[1].DisposedComponentIDs); + + // Outer component is still alive and not disposed. + Assert.False(component.Disposed); + Assert.Empty(renderer.HandledExceptions); + } + + [Fact] + public void RenderBatch_CanDisposeAsynchronousAsyncDisposables() + { + // Arrange + var semaphore = new Semaphore(0, 1); + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + renderer.OnExceptionHandled = () => semaphore.Release(); + var exception1 = new InvalidOperationException(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var firstRender = true; + var component = new TestComponent(builder => + { + if (firstRender) + { + builder.AddContent(0, "Hello"); + builder.OpenComponent(1); + builder.AddAttribute(1, nameof(AsyncDisposableComponent.AsyncDisposeAction), (Func)(async () => { await tcs.Task; })); + builder.CloseComponent(); + } + }); + + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + // Act: Second render + firstRender = false; + component.TriggerRender(); + + // Assert: Applicable children are included in disposal list + Assert.Equal(2, renderer.Batches.Count); + Assert.Equal(new[] { 1, }, renderer.Batches[1].DisposedComponentIDs); + + // Outer component is still alive and not disposed. + Assert.False(component.Disposed); + Assert.Empty(renderer.HandledExceptions); + + // Continue execution + tcs.SetResult(); + Assert.False(semaphore.WaitOne(10)); + Assert.Empty(renderer.HandledExceptions); + } + + [Fact] + public void RenderBatch_HandlesAsynchronousExceptionsInAsyncDisposableComponents() + { + // Arrange + var semaphore = new Semaphore(0, 1); + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + renderer.OnExceptionHandled = () => semaphore.Release(); + var exception1 = new InvalidOperationException(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var firstRender = true; + var component = new TestComponent(builder => + { + if (firstRender) + { + builder.AddContent(0, "Hello"); + builder.OpenComponent(1); + builder.AddAttribute(1, nameof(AsyncDisposableComponent.AsyncDisposeAction), (Func)(async () => { await tcs.Task; throw exception1; })); + builder.CloseComponent(); + } + }); + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + // Act: Second render + firstRender = false; + component.TriggerRender(); + + // Assert: Applicable children are included in disposal list + Assert.Equal(2, renderer.Batches.Count); + Assert.Equal(new[] { 1, }, renderer.Batches[1].DisposedComponentIDs); + + // Outer component is still alive and not disposed. + Assert.False(component.Disposed); + Assert.Empty(renderer.HandledExceptions); + + // Continue execution + tcs.SetResult(); + semaphore.WaitOne(); + var aex = Assert.IsType(Assert.Single(renderer.HandledExceptions)); + Assert.Same(exception1, aex); + } + + [Fact] + public void RenderBatch_ReportsSynchronousCancelationsAsErrors() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var firstRender = true; + var component = new TestComponent(builder => + { + if (firstRender) + { + builder.AddContent(0, "Hello"); + builder.OpenComponent(1); + builder.AddAttribute(1, nameof(AsyncDisposableComponent.AsyncDisposeAction), (Func)(() => throw new TaskCanceledException())); + builder.CloseComponent(); + } + }); + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Act: Second render + firstRender = false; + component.TriggerRender(); + + // Assert: Applicable children are included in disposal list + Assert.Equal(2, renderer.Batches.Count); + Assert.Equal(new[] { 1, }, renderer.Batches[1].DisposedComponentIDs); + + // Outer component is still alive and not disposed. + Assert.False(component.Disposed); + var aex = Assert.IsType(Assert.Single(renderer.HandledExceptions)); + Assert.IsType(Assert.Single(aex.Flatten().InnerExceptions)); + } + + [Fact] + public void RenderBatch_ReportsAsynchronousCancelationsAsErrors() + { + // Arrange + var semaphore = new Semaphore(0, 1); + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + renderer.OnExceptionHandled += () => semaphore.Release(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstRender = true; + var component = new TestComponent(builder => + { + if (firstRender) + { + builder.AddContent(0, "Hello"); + builder.OpenComponent(1); + builder.AddAttribute( + 1, + nameof(AsyncDisposableComponent.AsyncDisposeAction), + (Func)(() => new ValueTask(tcs.Task))); + builder.CloseComponent(); + } + }); + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Act: Second render + firstRender = false; + component.TriggerRender(); + + // Assert: Applicable children are included in disposal list + Assert.Equal(2, renderer.Batches.Count); + Assert.Equal(new[] { 1, }, renderer.Batches[1].DisposedComponentIDs); + + // Outer component is still alive and not disposed. + Assert.False(component.Disposed); + Assert.Empty(renderer.HandledExceptions); + + // Cancel execution + tcs.SetCanceled(); + + semaphore.WaitOne(); + var aex = Assert.IsType(Assert.Single(renderer.HandledExceptions)); + } + [Fact] public void RenderBatch_DoesNotDisposeComponentMultipleTimes() { @@ -2589,7 +2822,7 @@ public async Task CanCombineBindAndConditionalAttribute() // Act: Toggle the checkbox var eventArgs = new ChangeEventArgs { Value = true }; - var renderTask = renderer.DispatchEventAsync(checkboxChangeEventHandlerId, eventArgs); + var renderTask = renderer.DispatchEventAsync(checkboxChangeEventHandlerId, eventArgs); Assert.True(renderTask.IsCompletedSuccessfully); var latestBatch = renderer.Batches.Last(); @@ -3768,7 +4001,7 @@ public void CanUseCustomComponentActivatorFromServiceProvider() requestedType => Assert.Equal(typeof(TestComponent), requestedType)); } - private class TestComponentActivator : IComponentActivator where TResult: IComponent, new() + private class TestComponentActivator : IComponentActivator where TResult : IComponent, new() { public List RequestedComponentTypes { get; } = new List(); @@ -4147,6 +4380,24 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } + private class AsyncDisposableComponent : AutoRenderComponent, IAsyncDisposable + { + public bool Disposed { get; private set; } + + [Parameter] + public Func AsyncDisposeAction { get; set; } + + public ValueTask DisposeAsync() + { + Disposed = true; + return AsyncDisposeAction == null ? default : AsyncDisposeAction.Invoke(); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + } + } + class TestAsyncRenderer : TestRenderer { public Task NextUpdateDisplayReturnTask { get; set; } diff --git a/src/Components/test/E2ETest/ServerExecutionTests/PrerenderingTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/PrerenderingTest.cs index a1c9e8e9b18b..9d2f4e3b4e06 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/PrerenderingTest.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/PrerenderingTest.cs @@ -44,6 +44,15 @@ public void CanTransitionFromPrerenderedToInteractiveMode() Browser.Equal("1", () => Browser.FindElement(By.Id("count")).Text); } + [Fact] + public void PrerenderingWaitsForAsyncDisposableComponents() + { + Navigate("/prerendered/prerendered-async-disposal"); + + // Prerendered output shows "not connected" + Browser.Equal("After async disposal", () => Browser.FindElement(By.Id("disposal-message")).Text); + } + [Fact] public void CanUseJSInteropFromOnAfterRenderAsync() { diff --git a/src/Components/test/testassets/BasicTestApp/AsyncDisposableComponent.razor b/src/Components/test/testassets/BasicTestApp/AsyncDisposableComponent.razor new file mode 100644 index 000000000000..70abb1c86df5 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/AsyncDisposableComponent.razor @@ -0,0 +1,11 @@ +@implements IAsyncDisposable +@code{ + [Parameter] public EventCallback SetMessage { get; set; } + + public async ValueTask DisposeAsync() + { + await SetMessage.InvokeAsync("Before async disposal"); + await Task.Yield(); + await SetMessage.InvokeAsync("After async disposal"); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/AsyncDisposalDuringInitialization.razor b/src/Components/test/testassets/BasicTestApp/AsyncDisposalDuringInitialization.razor new file mode 100644 index 000000000000..3a6b2eac8ca5 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/AsyncDisposalDuringInitialization.razor @@ -0,0 +1,25 @@ +@page "/prerendered-async-disposal" + +

+ This component shows that prerendering will work for components that implement IAsyncDisposable to finish + disposing before rendering the output as html. +

+ +

@_message

+ +@if (!_hideComponent) +{ + +} + +@code { + private bool _hideComponent = false; + private string _message = "Uninitialized"; + protected override async Task OnInitializedAsync() + { + await Task.Yield(); + _hideComponent = true; + } + + private void SetMessage(string message) => _message = message; +}