diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
index 8d36fe78ad80..f165e6f82489 100644
--- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
+++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
@@ -89,6 +89,7 @@
+
diff --git a/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs
index 363ad846cbf7..fa20650559b8 100644
--- a/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs
+++ b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs
@@ -152,6 +152,9 @@ public partial class QuickGrid : IAsyncDisposable
// If the PaginationState mutates, it raises this event. We use it to trigger a re-render.
private readonly EventCallbackSubscriber _currentPageItemsChanged;
+ // If the QuickGrid is disposed while the JS module is being loaded, we need to avoid calling JS methods
+ private bool _wasDisposed;
+
///
/// Constructs an instance of .
///
@@ -206,6 +209,12 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
if (firstRender)
{
_jsModule = await JS.InvokeAsync("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("init", _tableReference);
}
@@ -434,6 +443,7 @@ private string GridClass()
///
public async ValueTask DisposeAsync()
{
+ _wasDisposed = true;
_currentPageItemsChanged.Dispose();
try
diff --git a/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/FailingQuickGrid.cs b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/FailingQuickGrid.cs
new file mode 100644
index 000000000000..d7715af78e57
--- /dev/null
+++ b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/FailingQuickGrid.cs
@@ -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;
+
+///
+/// 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.
+///
+/// The type of data represented by each row in the grid.
+internal class FailingQuickGrid : QuickGrid, IAsyncDisposable
+{
+ [Inject] private IJSRuntime JS { get; set; } = default!;
+
+ private readonly TaskCompletionSource _onAfterRenderCompleted = new();
+
+ public bool DisposeAsyncWasCalled { get; private set; }
+
+ ///
+ /// Task that completes when OnAfterRenderAsync has finished executing.
+ /// This allows tests to wait deterministically for the race condition to occur.
+ ///
+ public Task OnAfterRenderCompleted => _onAfterRenderCompleted.Task;
+
+ ///
+ /// Intentionally does NOT call base.DisposeAsync() to prevent _disposeBool from being set.
+ /// This simulates the behavior before the fix was implemented.
+ ///
+ public new async ValueTask DisposeAsync()
+ {
+ DisposeAsyncWasCalled = true;
+ await Task.CompletedTask;
+ }
+
+ ///
+ /// Explicit interface implementation to ensure our disposal method is called.
+ ///
+ async ValueTask IAsyncDisposable.DisposeAsync()
+ {
+ await DisposeAsync();
+ }
+
+ ///
+ /// 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.
+ ///
+ public bool IsWasDisposedFalse()
+ {
+ var field = typeof(QuickGrid).GetField("_wasDisposed", BindingFlags.NonPublic | BindingFlags.Instance);
+ return field?.GetValue(this) is false;
+ }
+
+ ///
+ /// Override OnAfterRenderAsync to simulate the race condition by NOT checking _disposeBool.
+ /// This exactly replicates the code path that existed before the race condition fix.
+ ///
+ 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("import",
+ "./_content/Microsoft.AspNetCore.Components.QuickGrid/QuickGrid.razor.js");
+ await jsModule.InvokeAsync("init", new object());
+ }
+ }
+ }
+ finally
+ {
+ if (firstRender)
+ {
+ _onAfterRenderCompleted.TrySetResult();
+ }
+ }
+ }
+}
diff --git a/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/GridRaceConditionTest.cs b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/GridRaceConditionTest.cs
new file mode 100644
index 000000000000..ffa4e6d3b409
--- /dev/null
+++ b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/GridRaceConditionTest.cs
@@ -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(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(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 : ComponentBase
+ where TGrid : ComponentBase
+{
+ [Inject] public IJSRuntime JSRuntime { get; set; } = default!;
+
+ protected TGrid _grid;
+ public TGrid Grid => _grid;
+
+ private readonly List _people = [
+ new() { Id = 1, Name = "John" },
+ new() { Id = 2, Name = "Jane" }
+ ];
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, "Items", _people.AsQueryable());
+ builder.AddAttribute(2, "ChildContent", (RenderFragment)(b =>
+ {
+ b.OpenComponent>(0);
+ b.AddAttribute(1, "Property", (System.Linq.Expressions.Expression>)(p => p.Id));
+ b.CloseComponent();
+ }));
+ builder.AddComponentReferenceCapture(3, component => _grid = (TGrid)component);
+ builder.CloseComponent();
+ }
+}
+
+internal class SimpleTestComponent : BaseTestComponent>
+{
+ public NotFailingGrid NotFailingGrid => Grid;
+}
+
+internal class FailingGridTestComponent : BaseTestComponent>
+{
+ public FailingQuickGrid 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 InvokeAsync(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 InvokeAsync(string identifier, CancellationToken cancellationToken, object[] args) =>
+ InvokeAsync(identifier, args);
+}
+
+internal class TestJSObjectReference(TestJsRuntime jsRuntime) : IJSObjectReference
+{
+ private readonly TestJsRuntime _jsRuntime = jsRuntime;
+
+ public ValueTask InvokeAsync(string identifier, object[] args)
+ {
+ if (identifier == "init")
+ {
+ _jsRuntime.RecordInitCall();
+ }
+ return default!;
+ }
+
+ public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object[] args) =>
+ InvokeAsync(identifier, args);
+
+ public ValueTask DisposeAsync() {
+ _jsRuntime.MarkJsModuleDisposed();
+ return default!;
+ }
+}
diff --git a/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/Microsoft.AspNetCore.Components.QuickGrid.Tests.csproj b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/Microsoft.AspNetCore.Components.QuickGrid.Tests.csproj
index 402e6a12a8f7..21eb671ff544 100644
--- a/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/Microsoft.AspNetCore.Components.QuickGrid.Tests.csproj
+++ b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/Microsoft.AspNetCore.Components.QuickGrid.Tests.csproj
@@ -1,4 +1,4 @@
-
+
$(DefaultNetCoreTargetFramework)
@@ -8,8 +8,10 @@
-
-
+
+
+
+
diff --git a/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/NotFailingQuickGrid.cs b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/NotFailingQuickGrid.cs
new file mode 100644
index 000000000000..b84d8c7b66fd
--- /dev/null
+++ b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/NotFailingQuickGrid.cs
@@ -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;
+///
+/// A QuickGrid implementation that uses the same implementation of the basic QuickGrid with the additions of the OnAfterRenderCompleted task.
+///
+/// /// The type of data represented by each row in the grid.
+internal class NotFailingGrid : QuickGrid
+{
+ [Inject] private IJSRuntime JS { get; set; } = default!;
+
+ private readonly TaskCompletionSource _onAfterRenderCompleted = new();
+
+ ///
+ /// Task that completes when OnAfterRenderAsync has finished executing.
+ /// This allows tests to wait deterministically for the race condition to occur.
+ ///
+ public Task OnAfterRenderCompleted => _onAfterRenderCompleted.Task;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ try
+ {
+ if (firstRender)
+ {
+ await base.OnAfterRenderAsync(firstRender);
+ }
+ }
+ finally
+ {
+ if (firstRender)
+ {
+ _onAfterRenderCompleted.TrySetResult();
+ }
+ }
+ }
+}