-
Notifications
You must be signed in to change notification settings - Fork 10.4k
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
ilonatommy
merged 8 commits into
dotnet:main
from
dariatiurina:47173-quickgrid-race-condition
Jul 28, 2025
+344
−3
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
ecdbe6b
Fixed race condition in the disposing of the QuickGrid
dariatiurina 1be6427
WIP: Update tests to check for fixed and not fixed scenario.
ilonatommy f086aaf
Cleanup.
ilonatommy 32d0698
Fixes and feedback
dariatiurina a4f3065
Fixes
dariatiurina 540c4fb
Feedback
dariatiurina 274e71b
Update src/Components/QuickGrid/Microsoft.AspNetCore.Components.Quick…
dariatiurina d885d72
Feedback
dariatiurina File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
85 changes: 85 additions & 0 deletions
85
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/FailingQuickGrid.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
} | ||
} |
200 changes: 200 additions & 0 deletions
200
...ponents/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/GridRaceConditionTest.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
ilonatommy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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!; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
43 changes: 43 additions & 0 deletions
43
...omponents/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/NotFailingQuickGrid.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.