Skip to content

Fixed race condition in the disposing of the QuickGrid #62840

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
<InternalsVisibleTo Include="Microsoft.AspNetCore.Components.Tests" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Components.Endpoints.Tests" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Components.Web.Tests" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Components.QuickGrid.Tests" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Components.ProtectedBrowserStorage.Tests" />
<InternalsVisibleTo Include="Components.TestServer" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" Key="$(MoqPublicKey)" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ public partial class QuickGrid<TGridItem> : IAsyncDisposable
// If the PaginationState mutates, it raises this event. We use it to trigger a re-render.
private readonly EventCallbackSubscriber<PaginationState> _currentPageItemsChanged;

// If the QuickGrid is disposed while the JS module is being loaded, we need to avoid calling JS methods
private bool _wasDisposed;

/// <summary>
/// Constructs an instance of <see cref="QuickGrid{TGridItem}"/>.
/// </summary>
Expand Down Expand Up @@ -206,6 +209,12 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
if (firstRender)
{
_jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/Microsoft.AspNetCore.Components.QuickGrid/QuickGrid.razor.js");
if (_wasDisposed)
{
// If the component has been disposed while JS module was being loaded, we don't need to continue
await _jsModule.DisposeAsync();
return;
}
_jsEventDisposable = await _jsModule.InvokeAsync<IJSObjectReference>("init", _tableReference);
}

Expand Down Expand Up @@ -434,6 +443,7 @@ private string GridClass()
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
_wasDisposed = true;
_currentPageItemsChanged.Dispose();

try
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// 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.AspNetCore.Components.QuickGrid;
using Microsoft.JSInterop;
using System.Reflection;

namespace Microsoft.AspNetCore.Components.QuickGrid.Tests;

/// <summary>
/// A QuickGrid implementation that simulates the behavior before the race condition fix.
/// This class intentionally does NOT set _disposeBool during disposal to simulate the race condition.
/// </summary>
/// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
internal class FailingQuickGrid<TGridItem> : QuickGrid<TGridItem>, IAsyncDisposable
{
[Inject] private IJSRuntime JS { get; set; } = default!;

private readonly TaskCompletionSource _onAfterRenderCompleted = new();

public bool DisposeAsyncWasCalled { get; private set; }

/// <summary>
/// Task that completes when OnAfterRenderAsync has finished executing.
/// This allows tests to wait deterministically for the race condition to occur.
/// </summary>
public Task OnAfterRenderCompleted => _onAfterRenderCompleted.Task;

/// <summary>
/// Intentionally does NOT call base.DisposeAsync() to prevent _disposeBool from being set.
/// This simulates the behavior before the fix was implemented.
/// </summary>
public new async ValueTask DisposeAsync()
{
DisposeAsyncWasCalled = true;
await Task.CompletedTask;
}

/// <summary>
/// Explicit interface implementation to ensure our disposal method is called.
/// </summary>
async ValueTask IAsyncDisposable.DisposeAsync()
{
await DisposeAsync();
}

/// <summary>
/// Check if _disposeBool is false, proving we didn't call base.DisposeAsync().
/// This is used by tests to verify that our simulation is working correctly.
/// </summary>
public bool IsWasDisposedFalse()
{
var field = typeof(QuickGrid<TGridItem>).GetField("_wasDisposed", BindingFlags.NonPublic | BindingFlags.Instance);
return field?.GetValue(this) is false;
}

/// <summary>
/// Override OnAfterRenderAsync to simulate the race condition by NOT checking _disposeBool.
/// This exactly replicates the code path that existed before the race condition fix.
/// </summary>
protected override async Task OnAfterRenderAsync(bool firstRender)
{
try
{
if (firstRender)
{
if (JS != null)
{
// Import the JS module (this will trigger our TestJsRuntime's import logic)
var jsModule = await JS.InvokeAsync<IJSObjectReference>("import",
"./_content/Microsoft.AspNetCore.Components.QuickGrid/QuickGrid.razor.js");
await jsModule.InvokeAsync<IJSObjectReference>("init", new object());
}
}
}
finally
{
if (firstRender)
{
_onAfterRenderCompleted.TrySetResult();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// 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.Rendering;
using Microsoft.AspNetCore.Components.QuickGrid;
using Microsoft.AspNetCore.Components.Test.Helpers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using Xunit.Sdk;

namespace Microsoft.AspNetCore.Components.QuickGrid.Tests;

public class GridRaceConditionTest
{

[Fact]
public async Task CanCorrectlyDisposeAsync()
{
var moduleLoadCompletion = new TaskCompletionSource();
var moduleImportStarted = new TaskCompletionSource();
var testJsRuntime = new TestJsRuntime(moduleLoadCompletion, moduleImportStarted);
var serviceProvider = new ServiceCollection()
.AddSingleton<IJSRuntime>(testJsRuntime)
.BuildServiceProvider();
var renderer = new TestRenderer(serviceProvider);

var testComponent = new SimpleTestComponent();

var componentId = renderer.AssignRootComponentId(testComponent);
renderer.RenderRootComponent(componentId);

// Wait until JS import has started but not completed
await moduleImportStarted.Task;

// Dispose component while JS module loading is pending
testJsRuntime.MarkDisposed();
await renderer.DisposeAsync();

// Complete the JS module loading
moduleLoadCompletion.SetResult();

// Wait until after OnAfterRenderAsync has completed to test the disposal of the jsModule
var notFailingGrid = testComponent.NotFailingGrid;
await notFailingGrid.OnAfterRenderCompleted;

// Assert that init was not called after disposal and JsModule was disposed of
Assert.False(testJsRuntime.InitWasCalledAfterDisposal,
"Init should not be called on a disposed component.");
Assert.True(testJsRuntime.JsModuleDisposed);
}

[Fact]
public async Task FailingQuickGridCallsInitAfterDisposal()
{
var moduleLoadCompletion = new TaskCompletionSource();
var moduleImportStarted = new TaskCompletionSource();
var testJsRuntime = new TestJsRuntime(moduleLoadCompletion, moduleImportStarted);
var serviceProvider = new ServiceCollection()
.AddSingleton<IJSRuntime>(testJsRuntime)
.BuildServiceProvider();
var renderer = new TestRenderer(serviceProvider);

var testComponent = new FailingGridTestComponent();

var componentId = renderer.AssignRootComponentId(testComponent);
renderer.RenderRootComponent(componentId);

// Wait until JS import has started but not completed
await moduleImportStarted.Task;

// Dispose component while JS module loading is pending
testJsRuntime.MarkDisposed();
await renderer.DisposeAsync();

// Complete the JS module loading - this allows the FailingQuickGrid's OnAfterRenderAsync to continue
// and demonstrate the race condition by calling init after disposal
moduleLoadCompletion.SetResult();

// Wait until after OnAfterRenderAsync has completed, to make sure jsmodule import started and the reported issue is reproduced
var failingGrid = testComponent.FailingQuickGrid;
await failingGrid.OnAfterRenderCompleted;

// Assert that init WAS called after disposal
// The FailingQuickGrid's OnAfterRenderAsync should have called init despite being disposed
// The FailingQuickGrid should not have disposed of JsModule
Assert.True(testJsRuntime.InitWasCalledAfterDisposal,
$"FailingQuickGrid should call init after disposal, demonstrating the race condition bug. " +
$"InitWasCalledAfterDisposal: {testJsRuntime.InitWasCalledAfterDisposal}, " +
$"DisposeAsyncWasCalled: {failingGrid.DisposeAsyncWasCalled}, " +
$"_disposeBool is false: {failingGrid.IsWasDisposedFalse()}");
Assert.False(testJsRuntime.JsModuleDisposed);
}
}

internal class Person
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}

internal abstract class BaseTestComponent<TGrid> : ComponentBase
where TGrid : ComponentBase
{
[Inject] public IJSRuntime JSRuntime { get; set; } = default!;

protected TGrid _grid;
public TGrid Grid => _grid;

private readonly List<Person> _people = [
new() { Id = 1, Name = "John" },
new() { Id = 2, Name = "Jane" }
];

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<TGrid>(0);
builder.AddAttribute(1, "Items", _people.AsQueryable());
builder.AddAttribute(2, "ChildContent", (RenderFragment)(b =>
{
b.OpenComponent<PropertyColumn<Person, int>>(0);
b.AddAttribute(1, "Property", (System.Linq.Expressions.Expression<Func<Person, int>>)(p => p.Id));
b.CloseComponent();
}));
builder.AddComponentReferenceCapture(3, component => _grid = (TGrid)component);
builder.CloseComponent();
}
}

internal class SimpleTestComponent : BaseTestComponent<NotFailingGrid<Person>>
{
public NotFailingGrid<Person> NotFailingGrid => Grid;
}

internal class FailingGridTestComponent : BaseTestComponent<FailingQuickGrid<Person>>
{
public FailingQuickGrid<Person> FailingQuickGrid => Grid;
}

internal class TestJsRuntime(TaskCompletionSource moduleCompletion, TaskCompletionSource importStarted) : IJSRuntime
{
private readonly TaskCompletionSource _moduleCompletion = moduleCompletion;
private readonly TaskCompletionSource _importStarted = importStarted;
private bool _disposed;

public bool JsModuleDisposed { get; private set; }

public bool InitWasCalledAfterDisposal { get; private set; }

public async ValueTask<TValue> InvokeAsync<TValue>(string identifier, object[] args = null)
{
if (identifier == "import" && args?.Length > 0 && args[0] is string modulePath &&
modulePath == "./_content/Microsoft.AspNetCore.Components.QuickGrid/QuickGrid.razor.js")
{
// Signal that import has started
_importStarted.TrySetResult();

// Wait for test to control when import completes
await _moduleCompletion.Task;
return (TValue)(object)new TestJSObjectReference(this);
}
throw new InvalidOperationException($"Unexpected JS call: {identifier}");
}

public void MarkDisposed() => _disposed = true;

public void MarkJsModuleDisposed() => JsModuleDisposed = true;

public void RecordInitCall()
{
if (_disposed)
{
InitWasCalledAfterDisposal = true;
}
}

public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object[] args) =>
InvokeAsync<TValue>(identifier, args);
}

internal class TestJSObjectReference(TestJsRuntime jsRuntime) : IJSObjectReference
{
private readonly TestJsRuntime _jsRuntime = jsRuntime;

public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object[] args)
{
if (identifier == "init")
{
_jsRuntime.RecordInitCall();
}
return default!;
}

public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object[] args) =>
InvokeAsync<TValue>(identifier, args);

public ValueTask DisposeAsync() {
_jsRuntime.MarkJsModuleDisposed();
return default!;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
Expand All @@ -8,8 +8,10 @@
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Components.QuickGrid" />
</ItemGroup>



<ItemGroup>
<Compile Include="$(ComponentsSharedSourceRoot)test\**\*.cs" LinkBase="Helpers" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.JSInterop;

namespace Microsoft.AspNetCore.Components.QuickGrid.Tests;
/// <summary>
/// A QuickGrid implementation that uses the same implementation of the basic QuickGrid with the additions of the OnAfterRenderCompleted task.
/// </summary>
/// /// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
internal class NotFailingGrid<TGridItem> : QuickGrid<TGridItem>
{
[Inject] private IJSRuntime JS { get; set; } = default!;

private readonly TaskCompletionSource _onAfterRenderCompleted = new();

/// <summary>
/// Task that completes when OnAfterRenderAsync has finished executing.
/// This allows tests to wait deterministically for the race condition to occur.
/// </summary>
public Task OnAfterRenderCompleted => _onAfterRenderCompleted.Task;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
try
{
if (firstRender)
{
await base.OnAfterRenderAsync(firstRender);
}
}
finally
{
if (firstRender)
{
_onAfterRenderCompleted.TrySetResult();
}
}
}
}
Loading