diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 372393c31..bfb6e7112 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -17,7 +17,7 @@
"rollForward": false
},
"dotnet-dump": {
- "version": "10.0.731102",
+ "version": "10.0.745401",
"commands": [
"dotnet-dump"
],
diff --git a/.editorconfig b/.editorconfig
index 1fbb86aae..6929f0262 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -467,6 +467,7 @@ dotnet_diagnostic.BL0005.severity = none
dotnet_diagnostic.BL0006.severity = none
dotnet_diagnostic.BL0007.severity = none
dotnet_diagnostic.BL0010.severity = none
+dotnet_diagnostic.BL0012.severity = none
##########################################
# Custom Test Code Analyzers Rules
diff --git a/.globalconfig b/.globalconfig
new file mode 100644
index 000000000..c872976b8
--- /dev/null
+++ b/.globalconfig
@@ -0,0 +1,6 @@
+# Razor compiler diagnostics reported on .razor files are not matched by .editorconfig
+# path sections, so their severity can only be configured globally.
+is_global = true
+
+dotnet_diagnostic.BL0013.severity = none
+dotnet_diagnostic.BL0016.severity = none
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5760e951..525af7b12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,11 @@ All notable changes to **bUnit** will be documented in this file. The project ad
## [Unreleased]
+### Fixed
+
+- `InvokeOnSpacerBeforeVisible` now uses 4 parameters on .NET 11.0. Reported by [@vnbaaij](https://github.com/vnbaaij) in #1915. Fixed by [@vnbaaij](https://github.com/vnbaaij) in #1919.
+- A JSInterop timeout elapsing while a result was set could crash the test host with `InvalidOperationException: Nullable object must have a value`. Reported by [@calebcwells](https://github.com/calebcwells) in [#1920](https://github.com/bUnit-dev/bUnit/issues/1920). Fixed by [@linkdotnet](https://github.com/linkdotnet).
+
## [2.10.3] - 2026-09-08
### Fixed
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 56664c00a..04c706758 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,7 +8,7 @@
-
+
@@ -18,8 +18,8 @@
-
-
+
+
@@ -72,18 +72,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -91,7 +91,7 @@
-
+
diff --git a/bunit.slnx b/bunit.slnx
index 8c453b444..3a300cb00 100644
--- a/bunit.slnx
+++ b/bunit.slnx
@@ -3,6 +3,7 @@
+
diff --git a/src/bunit/JSInterop/InvocationHandlers/Implementation/VirtualizeJSRuntimeInvocationHandler.cs b/src/bunit/JSInterop/InvocationHandlers/Implementation/VirtualizeJSRuntimeInvocationHandler.cs
index 2fb2abd73..a771cd259 100644
--- a/src/bunit/JSInterop/InvocationHandlers/Implementation/VirtualizeJSRuntimeInvocationHandler.cs
+++ b/src/bunit/JSInterop/InvocationHandlers/Implementation/VirtualizeJSRuntimeInvocationHandler.cs
@@ -1,6 +1,6 @@
-using Microsoft.AspNetCore.Components.Web.Virtualization;
using System.Diagnostics;
using System.Reflection;
+using Microsoft.AspNetCore.Components.Web.Virtualization;
namespace Bunit.JSInterop.InvocationHandlers.Implementation;
@@ -35,10 +35,15 @@ internal VirtualizeJSRuntimeInvocationHandler()
///
protected internal override Task HandleAsync(JSRuntimeInvocation invocation)
{
- if (!invocation.Identifier.Equals(JsFunctionsPrefix + "dispose", StringComparison.Ordinal))
+ if (!invocation.Identifier.Equals(JsFunctionsPrefix + "dispose", StringComparison.Ordinal) &&
+ !invocation.Identifier.Equals(JsFunctionsPrefix + "refreshObservers", StringComparison.Ordinal))
{
Debug.Assert(invocation.Identifier.Equals(JsFunctionsPrefix + "init", StringComparison.Ordinal));
+#if NET11_0_OR_GREATER
+ Debug.Assert(invocation.Arguments.Count == 4);
+#else
Debug.Assert(invocation.Arguments.Count == 3);
+#endif
Debug.Assert(invocation.Arguments[0] is not null);
InvokeOnSpacerBeforeVisible(invocation.Arguments[0]!);
@@ -58,7 +63,12 @@ private static void InvokeOnSpacerBeforeVisible(object dotNetObjectReference)
0f, /* spacerSize */
0f, /* spacerSeparation */
1_000_000_000f, /* containerSize - very large number to ensure all items are loaded at once */
+#if NET11_0_OR_GREATER
+ 0, /* UserScroll */
+#endif
+
};
+
onSpacerBeforeVisibleMethodInfo.Invoke(virtualizeJsInterop, parameters);
}
}
diff --git a/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs b/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs
index 5aa093eb2..53026ff4d 100644
--- a/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs
+++ b/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs
@@ -1,14 +1,20 @@
+using System.Collections.Concurrent;
+
namespace Bunit;
+// Invocation tracking mirrors ASP.NET Core's JSRuntime: no per-invocation state lives in instance
+// fields. Each call gets its own TaskCompletionSource in a ConcurrentDictionary keyed by an
+// Interlocked id, and the timeout closes over that entry alone, so an elapsing timeout can never
+// race a concurrently set result. See https://github.com/dotnet/aspnetcore/blob/main/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs
///
/// Represents an invocation handler for instances.
///
public abstract class JSRuntimeInvocationHandlerBase : IDisposable
{
private readonly InvocationMatcher invocationMatcher;
- private TaskCompletionSource completionSource;
- private Timer? timeoutTimer;
- private JSRuntimeInvocation? currentInvocation;
+ private readonly ConcurrentDictionary pendingInvocations = new();
+ private long nextInvocationId;
+ private Task? outcome;
private bool disposed;
///
@@ -34,7 +40,6 @@ public abstract class JSRuntimeInvocationHandlerBase : IDisposable
protected JSRuntimeInvocationHandlerBase(InvocationMatcher matcher, bool isCatchAllHandler)
{
invocationMatcher = matcher ?? throw new ArgumentNullException(nameof(matcher));
- completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
IsCatchAllHandler = isCatchAllHandler;
}
@@ -42,13 +47,7 @@ protected JSRuntimeInvocationHandlerBase(InvocationMatcher matcher, bool isCatch
/// Marks the that invocations will receive as canceled.
///
protected void SetCanceledBase()
- {
- ClearTimeoutTimer();
- if (completionSource.Task.IsCompleted)
- completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-
- completionSource.SetCanceled();
- }
+ => CompleteAll(Task.FromCanceled(new CancellationToken(canceled: true)));
///
/// Sets the exception that invocations will receive.
@@ -56,26 +55,14 @@ protected void SetCanceledBase()
/// The type of exception to pass to the callers.
protected void SetExceptionBase(TException exception)
where TException : Exception
- {
- ClearTimeoutTimer();
- if (completionSource.Task.IsCompleted)
- completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-
- completionSource.SetException(exception);
- }
+ => CompleteAll(Task.FromException(exception));
///
/// Sets the result that invocations will receive.
///
/// The type of result to pass to the callers.
protected void SetResultBase(TResult result)
- {
- ClearTimeoutTimer();
- if (completionSource.Task.IsCompleted)
- completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-
- completionSource.SetResult(result);
- }
+ => CompleteAll(Task.FromResult(result));
///
/// Call this to have the this handler handle the .
@@ -89,18 +76,29 @@ protected internal virtual Task HandleAsync(JSRuntimeInvocation invocat
{
Invocations.RegisterInvocation(invocation);
- var task = completionSource.Task;
- if (task is { IsCanceled: false, IsFaulted: false, IsCompletedSuccessfully: false })
+ if (Volatile.Read(ref outcome) is { } configured)
+ return configured;
+
+ var timeout = BunitContext.DefaultWaitTimeout;
+ if (timeout <= TimeSpan.Zero)
{
- if (BunitContext.DefaultWaitTimeout <= TimeSpan.Zero)
- {
- throw new JSRuntimeInvocationNotSetException(invocation);
- }
+ throw new JSRuntimeInvocationNotSetException(invocation);
+ }
+
+ var id = Interlocked.Increment(ref nextInvocationId);
+ var pending = new PendingInvocation(id, invocation);
+ pendingInvocations[id] = pending;
- StartTimeoutTimer(invocation);
+ if (Volatile.Read(ref outcome) is { } raced && pendingInvocations.TryRemove(id, out _))
+ {
+ Transfer(raced, pending.CompletionSource);
+ }
+ else
+ {
+ pending.StartTimeout(OnTimeoutElapsed, timeout);
}
- return task;
+ return pending.CompletionSource.Task;
}
///
@@ -122,34 +120,71 @@ protected virtual void Dispose(bool disposing)
{
if (!disposed && disposing)
{
- ClearTimeoutTimer();
+ foreach (var id in pendingInvocations.Keys)
+ {
+ if (pendingInvocations.TryRemove(id, out var pending))
+ pending.Dispose();
+ }
+
disposed = true;
}
}
- private void StartTimeoutTimer(JSRuntimeInvocation invocation)
+ private void CompleteAll(Task next)
{
- ClearTimeoutTimer();
+ Volatile.Write(ref outcome, next);
- currentInvocation = invocation;
- timeoutTimer = new Timer(OnTimeoutElapsed, null, BunitContext.DefaultWaitTimeout, Timeout.InfiniteTimeSpan);
+ foreach (var id in pendingInvocations.Keys)
+ {
+ if (pendingInvocations.TryRemove(id, out var pending))
+ {
+ pending.Dispose();
+ Transfer(next, pending.CompletionSource);
+ }
+ }
}
- private void ClearTimeoutTimer()
+ private void OnTimeoutElapsed(object? state)
{
- timeoutTimer?.Dispose();
- timeoutTimer = null;
- currentInvocation = null;
+ if (state is not PendingInvocation pending || !pendingInvocations.TryRemove(pending.Id, out _))
+ return;
+
+ pending.Dispose();
+ pending.CompletionSource.TrySetException(new JSRuntimeInvocationNotSetException(pending.Invocation));
}
- private void OnTimeoutElapsed(object? state)
+ private static void Transfer(Task from, TaskCompletionSource to)
{
- if (!completionSource.Task.IsCompleted && currentInvocation.HasValue)
+ if (from.IsCanceled)
+ to.TrySetCanceled();
+ else if (from.Exception is { } exception)
+ to.TrySetException(exception.InnerExceptions);
+ else
+ to.TrySetResult(from.Result);
+ }
+
+ private sealed class PendingInvocation : IDisposable
+ {
+ private Timer? timeoutTimer;
+
+ public long Id { get; }
+
+ public JSRuntimeInvocation Invocation { get; }
+
+ public TaskCompletionSource CompletionSource { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public PendingInvocation(long id, JSRuntimeInvocation invocation)
+ {
+ Id = id;
+ Invocation = invocation;
+ }
+
+ public void StartTimeout(TimerCallback callback, TimeSpan timeout)
{
- var exception = new JSRuntimeInvocationNotSetException(currentInvocation.Value);
- completionSource.TrySetException(exception);
+ timeoutTimer = new Timer(callback, this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
+ timeoutTimer.Change(timeout, Timeout.InfiniteTimeSpan);
}
- ClearTimeoutTimer();
+ public void Dispose() => timeoutTimer?.Dispose();
}
}
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
index cf491ca78..0b5093695 100644
--- a/tests/Directory.Build.props
+++ b/tests/Directory.Build.props
@@ -14,9 +14,9 @@
true
Exe
-
+
-
diff --git a/tests/bunit.testassets/SampleComponents/SimpleAuthViewWithClaims.razor b/tests/bunit.testassets/SampleComponents/SimpleAuthViewWithClaims.razor
index d889342d0..3fd2357d4 100644
--- a/tests/bunit.testassets/SampleComponents/SimpleAuthViewWithClaims.razor
+++ b/tests/bunit.testassets/SampleComponents/SimpleAuthViewWithClaims.razor
@@ -3,30 +3,30 @@
@inject AuthenticationStateProvider AuthenticationStateProvider
- Authorized!
- Name: @userName
- @if (hasUserEmail)
- {
- Email: @userEmail
- }
- @if (hasUserId)
- {
- Id: @userId
- }
+ Authorized!
+ Name: @userName
+ @if (hasUserEmail)
+ {
+ Email: @userEmail
+ }
+ @if (hasUserId)
+ {
+ Id: @userId
+ }
@code {
- string userName = "";
- string? userEmail = "";
- string? userId = "";
- bool hasUserEmail => userEmail != null;
- bool hasUserId => userId != null;
+ string userName = "";
+ string? userEmail = "";
+ string? userId = "";
+ bool hasUserEmail => userEmail != null;
+ bool hasUserId => userId != null;
- protected override async Task OnParametersSetAsync()
- {
- var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
- userName = state?.User?.Identity?.Name ?? string.Empty;
- userEmail = state?.User?.FindFirst(ClaimTypes.Email)?.Value;
- userId = state?.User?.FindFirst(ClaimTypes.Sid)?.Value;
- }
+ protected override async Task OnParametersSetAsync()
+ {
+ var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
+ userName = state?.User?.Identity?.Name ?? string.Empty;
+ userEmail = state?.User?.FindFirst(ClaimTypes.Email)?.Value;
+ userId = state?.User?.FindFirst(ClaimTypes.Sid)?.Value;
+ }
}
diff --git a/tests/bunit.testassets/SampleComponents/SimpleWithHttpClient.razor b/tests/bunit.testassets/SampleComponents/SimpleWithHttpClient.razor
index b3ec30bf0..97468e805 100644
--- a/tests/bunit.testassets/SampleComponents/SimpleWithHttpClient.razor
+++ b/tests/bunit.testassets/SampleComponents/SimpleWithHttpClient.razor
@@ -1,4 +1,4 @@
-@inject HttpClient HttpClient
+@inject HttpClient HttpClient
SimpleWithHttpClient
@@ -7,6 +7,5 @@
protected override async Task OnInitializedAsync()
{
await HttpClient.GetAsync("/api/weather");
- StateHasChanged();
}
}
diff --git a/tests/bunit.testassets/SampleComponents/SimpleWithJSRuntimeDep.razor b/tests/bunit.testassets/SampleComponents/SimpleWithJSRuntimeDep.razor
index 4c8f6d1d0..c6f15920c 100644
--- a/tests/bunit.testassets/SampleComponents/SimpleWithJSRuntimeDep.razor
+++ b/tests/bunit.testassets/SampleComponents/SimpleWithJSRuntimeDep.razor
@@ -1,6 +1,6 @@
-@inject IJSRuntime jsRuntime
+@inject IJSRuntime jsRuntime
@name
-@code{
+@code {
string name = string.Empty;
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -11,4 +11,4 @@
StateHasChanged();
}
}
-}
\ No newline at end of file
+}
diff --git a/tests/bunit.testassets/bunit.testassets.csproj b/tests/bunit.testassets/bunit.testassets.csproj
index 305d09964..b8d28daec 100644
--- a/tests/bunit.testassets/bunit.testassets.csproj
+++ b/tests/bunit.testassets/bunit.testassets.csproj
@@ -1,4 +1,4 @@
-
+
net8.0;net9.0;net10.0;net11.0
diff --git a/tests/bunit.tests/JSInterop/BunitJSInteropTimeoutTest.cs b/tests/bunit.tests/JSInterop/BunitJSInteropTimeoutTest.cs
index 811bd351c..eb5cc4a28 100644
--- a/tests/bunit.tests/JSInterop/BunitJSInteropTimeoutTest.cs
+++ b/tests/bunit.tests/JSInterop/BunitJSInteropTimeoutTest.cs
@@ -1,3 +1,5 @@
+using System.Diagnostics;
+
namespace Bunit.JSInterop;
[CollectionDefinition(nameof(DefaultWaitTimeoutTestGroup), DisableParallelization = true)]
@@ -12,12 +14,9 @@ public class BunitJSInteropTimeoutTest
public async Task Test309()
{
const string identifier = "testFunction";
- var originalTimeout = BunitContext.DefaultWaitTimeout;
- try
+ await WithDefaultWaitTimeout(TimeSpan.FromMilliseconds(100), async () =>
{
- BunitContext.DefaultWaitTimeout = TimeSpan.FromMilliseconds(100);
-
var sut = new BunitJSInterop { Mode = JSRuntimeMode.Strict };
sut.Setup(identifier);
@@ -25,6 +24,95 @@ public async Task Test309()
var exception = await Should.ThrowAsync(invocationTask.AsTask());
exception.Invocation.Identifier.ShouldBe(identifier);
+ });
+ }
+
+ [Fact(DisplayName = "Each pending invocation times out with its own invocation")]
+ public async Task Test310()
+ {
+ await WithDefaultWaitTimeout(TimeSpan.FromMilliseconds(100), async () =>
+ {
+ var sut = new BunitJSInterop { Mode = JSRuntimeMode.Strict };
+ sut.Setup(_ => true);
+
+ var first = sut.JSRuntime.InvokeAsync("first").AsTask();
+ var second = sut.JSRuntime.InvokeAsync("second").AsTask();
+
+ (await Should.ThrowAsync(first))
+ .Invocation.Identifier.ShouldBe("first");
+ (await Should.ThrowAsync(second))
+ .Invocation.Identifier.ShouldBe("second");
+ });
+ }
+
+ [Fact(DisplayName = "A timed out invocation does not affect later invocations")]
+ public async Task Test311()
+ {
+ const string identifier = "testFunction";
+
+ await WithDefaultWaitTimeout(TimeSpan.FromMilliseconds(100), async () =>
+ {
+ var sut = new BunitJSInterop { Mode = JSRuntimeMode.Strict };
+ var handler = sut.Setup(identifier);
+
+ await Should.ThrowAsync(
+ sut.JSRuntime.InvokeAsync(identifier).AsTask());
+
+ handler.SetResult(42);
+
+ (await sut.JSRuntime.InvokeAsync(identifier)).ShouldBe(42);
+ });
+ }
+
+ [Fact(DisplayName = "Setting a result while the timeout elapses does not crash the test host")]
+ public async Task Test312()
+ {
+ const string identifier = "testFunction";
+ var timeout = TimeSpan.FromMilliseconds(2);
+
+ await WithDefaultWaitTimeout(timeout, async () =>
+ {
+ var workers = Enumerable
+ .Range(0, Math.Max(4, Environment.ProcessorCount))
+ .Select(_ => Task.Run(() => RaceResultAgainstTimeout(identifier, timeout, iterations: 250)));
+
+ await Task.WhenAll(workers);
+ });
+ }
+
+ private static async Task RaceResultAgainstTimeout(string identifier, TimeSpan timeout, int iterations)
+ {
+ for (var i = 0; i < iterations; i++)
+ {
+ var sut = new BunitJSInterop { Mode = JSRuntimeMode.Strict };
+ var handler = sut.Setup(identifier);
+
+ var invocationTask = sut.JSRuntime.InvokeAsync(identifier).AsTask();
+
+ // Spin until the timer is due so that setting the result races the elapsing timeout.
+ var spin = Stopwatch.StartNew();
+ while (spin.Elapsed < timeout)
+ Thread.SpinWait(1);
+
+ handler.SetResult(i);
+
+ // Either the result or the timeout may win the race, but the invocation must
+ // always complete and never surface anything but the timeout exception.
+ var completed = await Task.WhenAny(invocationTask, Task.Delay(TimeSpan.FromSeconds(10)));
+ completed.ShouldBe(invocationTask);
+
+ if (invocationTask.Exception is { } exception)
+ exception.InnerException.ShouldBeOfType();
+ }
+ }
+
+ private static async Task WithDefaultWaitTimeout(TimeSpan timeout, Func test)
+ {
+ var originalTimeout = BunitContext.DefaultWaitTimeout;
+ BunitContext.DefaultWaitTimeout = timeout;
+ try
+ {
+ await test();
}
finally
{
diff --git a/tests/bunit.tests/bunit.tests.csproj b/tests/bunit.tests/bunit.tests.csproj
index 638453b1a..93b44ce54 100644
--- a/tests/bunit.tests/bunit.tests.csproj
+++ b/tests/bunit.tests/bunit.tests.csproj
@@ -7,12 +7,12 @@
-
+
-
+
\ No newline at end of file
diff --git a/version.json b/version.json
index cf16a5ace..3f50706f4 100644
--- a/version.json
+++ b/version.json
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
- "version": "2.10",
+ "version": "2.11",
"assemblyVersion": {
"precision": "revision"
},