| File: Controls\DisposalSafeFluentDataGridTests.cs | Web Access |
| Project: src\tests\Aspire.Dashboard.Components.Tests\Aspire.Dashboard.Components.Tests.csproj (Aspire.Dashboard.Components.Tests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Linq.Expressions; using Aspire.Dashboard.Components.Controls.Grid; using Aspire.Dashboard.Components.Tests.Shared; using Bunit; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.InternalTesting; using Microsoft.FluentUI.AspNetCore.Components; using Xunit; namespace Aspire.Dashboard.Components.Tests.Controls; public sealed class DisposalSafeFluentDataGridTests : DashboardTestContext { private const string DataGridModulePath = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/DataGrid/FluentDataGrid.razor.js"; private const string InitializeGrid = "Microsoft.FluentUI.Blazor.DataGrid.Initialize"; private const string EnableColumnResizing = "Microsoft.FluentUI.Blazor.DataGrid.EnableColumnResizing"; [Theory] [InlineData(false)] [InlineData(true)] public async Task RemovedWhileInitializing_IgnoresTheLateCallInsteadOfEndingTheCircuit(bool aspireGrid) { var (module, controller) = aspireGrid ? await RemoveGridWhileItInitializesAsync<AspireFluentDataGrid<string>>(resizableColumns: true) : await RemoveGridWhileItInitializesAsync<DisposalSafeFluentDataGrid<string>>(resizableColumns: true); // The grid resumed after disposal and reached its released module, which rejected the call. The grid ignored // the rejection, so nothing reached the renderer, which would have ended the circuit. The controller that // arrived after disposal was stopped and released. Assert.Equal([EnableColumnResizing], module.RejectedInvocations); Assert.False(Renderer.UnhandledException.IsCompleted); AssertStoppedAndReleased(controller); } [Fact] public async Task RemovedWhileInitializing_StopsTheGridControllerThatArrivesAfterDisposal() { // Without resizable or reorderable columns, the render that resumes after disposal makes no other call and // completes without an error, but the controller still arrives after Fluent UI's cleanup ran. var (module, controller) = await RemoveGridWhileItInitializesAsync<DisposalSafeFluentDataGrid<string>>(resizableColumns: false); Assert.Empty(module.RejectedInvocations); Assert.False(Renderer.UnhandledException.IsCompleted); AssertStoppedAndReleased(controller); } // Fluent UI bug https://github.com/microsoft/fluentui-blazor/issues/5580, which DisposalSafeFluentDataGrid works // around. This test fails once the Fluent UI version used here fixes it: then remove DisposalSafeFluentDataGrid, as // its remarks describe, and this file. [Fact] public async Task FluentDataGrid_StillCallsIntoItsDisposedModuleWhenRemovedWhileInitializing() { var (module, controller) = await RemoveGridWhileItInitializesAsync<FluentDataGrid<string>>(resizableColumns: true); Assert.Equal([EnableColumnResizing], module.RejectedInvocations); Assert.IsType<ObjectDisposedException>(await Renderer.UnhandledException.DefaultTimeout()); // Fluent UI also leaves the controller that arrived after disposal running. Assert.Empty(controller.Invocations); Assert.Equal(0, controller.DisposeCount); } private static void AssertStoppedAndReleased(TestJSObjectReference controller) { Assert.Equal(["stop"], controller.Invocations.Select(invocation => invocation.Identifier)); Assert.Equal(1, controller.DisposeCount); } // Renders a grid, removes it while its first OnAfterRenderAsync awaits the grid's JavaScript initialization, and // then lets the initialization complete, returning the grid's controller after disposal. This follows the order seen // in the dashboard: the grid renders its rows while its JavaScript module is still loading, which leaves the setup of // resizable columns for later, and is removed after the module arrives but before the grid's initialization returns. private async Task<(TestJSObjectReference Module, TestJSObjectReference Controller)> RemoveGridWhileItInitializesAsync<TGrid>(bool resizableColumns) where TGrid : FluentDataGrid<string> { FluentUISetupHelpers.AddCommonDashboardServices(this); FluentUISetupHelpers.SetupFluentUIComponents(this); var initializeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseInitialize = new TaskCompletionSource(); var controller = new TestJSObjectReference(); var module = new TestJSObjectReference { BeforeInvokeAsync = async identifier => { if (identifier == InitializeGrid) { initializeStarted.TrySetResult(); await releaseInitialize.Task; } }, GetResult = identifier => identifier == InitializeGrid ? controller : null }; var import = TestJSObjectReference.SetupImport(this, DataGridModulePath); var cut = Render<TGrid>(builder => builder .Add(grid => grid.ItemsProvider, EnumerableGridItemsProvider.Create(() => new[] { "value" })) .Add(grid => grid.ResizableColumns, resizableColumns) .Add(grid => grid.ChildContent, RenderColumn)); // The render that shows the rows comes before the module, so it leaves the column setup for later. cut.WaitForAssertion(() => Assert.Equal("value", cut.Find("[role=gridcell]").TextContent)); await Renderer.Dispatcher.InvokeAsync(() => { }); Assert.Empty(module.Invocations); import.SetResult(module); await initializeStarted.Task.DefaultTimeout(); // Removing the grid disposes it, and its module, while the initialization is pending. This test resumes on the // thread pool while the dispatcher may still be finishing the grid's work item, so the removal runs on the // dispatcher: from another thread, DisposeComponentsAsync queues the removal there but clears its list of // rendered components right away, and the queued removal can then find nothing to remove. await Renderer.Dispatcher.InvokeAsync(DisposeComponentsAsync); Assert.Equal(1, module.DisposeCount); // The initialization completes after disposal, so the grid's OnAfterRenderAsync resumes. Its continuation is // posted to the renderer's dispatcher, and runs before this empty work item queued after it. releaseInitialize.SetResult(); await Renderer.Dispatcher.InvokeAsync(() => { }); return (module, controller); static void RenderColumn(RenderTreeBuilder builder) { builder.OpenComponent<PropertyColumn<string, string>>(0); builder.AddComponentParameter(1, nameof(PropertyColumn<string, string>.Title), "Value"); builder.AddComponentParameter(2, nameof(PropertyColumn<string, string>.Property), (Expression<Func<string, string>>)(value => value)); builder.CloseComponent(); } } }