File: Components\Controls\Grid\DisposalSafeFluentDataGrid.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Components.Controls.Grid;
 
/// <summary>
/// A <see cref="FluentDataGrid{TGridItem}"/> that a removal during its initialization can't use to end the circuit.
/// Dashboard grids use it, or <see cref="AspireFluentDataGrid{TGridItem}"/>, instead of <see cref="FluentDataGrid{TGridItem}"/>.
/// </summary>
/// <remarks>
/// Workaround for https://github.com/microsoft/fluentui-blazor/issues/5580. Once the Fluent UI version used here
/// includes the fix, remove this class: make <see cref="AspireFluentDataGrid{TGridItem}"/> derive from
/// <see cref="FluentDataGrid{TGridItem}"/> again, use <c>FluentDataGrid</c> where markup uses <c>DisposalSafeFluentDataGrid</c>,
/// and remove DisposalSafeFluentDataGridTests. Its <c>FluentDataGrid_StillCallsIntoItsDisposedModuleWhenRemovedWhileInitializing</c>
/// test fails when the fix arrives.
/// </remarks>
[CascadingTypeParameter(nameof(TGridItem))]
public class DisposalSafeFluentDataGrid<TGridItem> : FluentDataGrid<TGridItem>
{
    // Fluent UI's own disposal flag (IFluentComponentBase.IsDisposed) is internal.
    private bool _disposalStarted;
 
    public DisposalSafeFluentDataGrid(LibraryConfiguration configuration) : base(configuration)
    {
    }
 
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        try
        {
            await base.OnAfterRenderAsync(firstRender);
        }
        catch (ObjectDisposedException) when (_disposalStarted)
        {
            // FluentDataGrid.OnAfterRenderAsync awaits JavaScript calls, such as initializing the grid. A grid removed
            // during one of them, for example by a layout change right after it rendered, resumes after disposal and
            // calls into the JavaScript module that disposal released: FluentJSModule.Imported stays true after the
            // module is disposed, so the grid's own check doesn't catch it. The ObjectDisposedException then escapes
            // OnAfterRenderAsync, and Blazor Server ends the circuit, which stops every view in the browser tab.
            //
            // The grid is gone, so the calls it skips here (column resizing and reordering, auto-fit, popup position)
            // have nothing left to set up. Only that case is ignored: the same exception while the grid is still in
            // use is a real error and still surfaces.
        }
 
        if (_disposalStarted)
        {
            await StopGridControllerAfterDisposalAsync();
        }
    }
 
    // Fluent UI stops the grid's JavaScript controller when the grid is disposed, but only a controller that has arrived.
    // When disposal runs while the grid's Initialize call is pending, the controller arrives afterwards, whether or not
    // the render then fails, and keeps the removed grid registered in the browser with its click, mousedown and keydown
    // listeners on document.body. Fluent UI's controller cleanup is DisposeAsync(IJSObjectReference): it stops and
    // releases the controller once and does nothing after that, so it's run again here once a render that resumed after
    // disposal completes. Its argument is unused by FluentDataGrid.
    private async Task StopGridControllerAfterDisposalAsync()
    {
        if (!JSModule.Imported)
        {
            return;
        }
 
        try
        {
            await DisposeAsync(JSModule.ObjectReference);
        }
        catch (Exception ex) when (ex is JSDisconnectedException or OperationCanceledException)
        {
            // The circuit is gone, and the browser's controller with it.
        }
    }
 
    public override ValueTask DisposeAsync()
    {
        // Set before Fluent UI releases the grid's JavaScript module, so OnAfterRenderAsync can tell that a rejected
        // call came from a grid that's being removed.
        _disposalStarted = true;
        return base.DisposeAsync();
    }
}