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(); + } + } + } +}