diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml b/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml
index 13869c4d..34a3683d 100644
--- a/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml
+++ b/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml
@@ -63,6 +63,16 @@
If true disables paste to input component. Default is false.
+
+
+ Override to read Value from ParameterState instead of backing field.
+
+
+
+
+ Override to write Value to ParameterState instead of backing field.
+
+
Invokes logic to be executed before input is processed, including raising the BeforeInput event if a
@@ -1748,13 +1758,18 @@
-
+ An extended base class for designing input components which update after a delay.
+
+ The type of object managed by this input.
+
+
+
+ Initializes a new instance of the class.
-
- Interval to be awaited in milliseconds before changing the Text value
+ The number of milliseconds to wait before updating the value.
@@ -1763,28 +1778,20 @@
receives the Text as a parameter
-
-
-
-
-
+
+
-
-
-
-
-
+
+
+
+
-
-
-
+
-
-
-
+
@@ -6487,6 +6494,154 @@
Localized strings for DateWheelPicker.
+
+
+ Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+
+
+
+ This dispatcher implements debouncing with optional leading-edge execution.
+ In trailing mode (default), the action executes only after the specified interval has passed
+ with no new invocations. In leading mode, the first call executes immediately, then subsequent
+ calls are debounced.
+
+
+ Thread Safety: This class is thread-safe. Multiple concurrent calls to
+ are properly synchronized.
+
+
+ Guarantees:
+
+ - In trailing mode: Only the last invocation's action will execute after the interval elapses.
+ - In leading mode: First call executes immediately, subsequent calls within the interval are debounced.
+ - Previous pending invocations are automatically cancelled.
+ - Exceptions thrown by the action are propagated to the caller.
+ - Disposal cancels any pending invocation.
+
+
+
+
+
+
+ Indicates whether a debounce delay is currently pending.
+
+
+
+
+ Initializes a new instance of the class with the specified interval.
+
+ The debounce interval in milliseconds. Must be non-negative.
+ If true, executes on the leading edge (immediately on first call). Default is false (trailing edge).
+ Thrown when interval is negative.
+
+
+
+ Initializes a new instance of the class with the specified interval.
+
+ The debounce interval as a . Must be non-negative.
+ If true, executes on the leading edge (immediately on first call). Default is false (trailing edge).
+ Thrown when interval is negative.
+
+
+
+ Initializes a new instance of the class with the specified interval and time provider.
+
+ The debounce interval in milliseconds. Must be non-negative.
+ If true, executes on the leading edge (immediately on first call). Default is false (trailing edge).
+ The time provider to use for delays and time queries.
+ Thrown when TimeProvider is null.
+ Thrown when interval is negative.
+
+
+
+ Initializes a new instance of the class with the specified interval and time provider.
+
+ The debounce interval as a . Must be non-negative.
+ If true, executes on the leading edge (immediately on first call). Default is false (trailing edge).
+ The time provider to use for delays and time queries.
+ Thrown when TimeProvider is null.
+ Thrown when interval is negative.
+
+
+
+ Debounces the execution of an asynchronous action.
+
+
+
+ In trailing mode (default): Each call cancels any previously pending action and starts a new timer.
+ The action executes only if no new calls occur within the configured interval.
+
+
+ In leading mode: The first call (or first call after the interval expires) executes immediately.
+ Subsequent calls within the interval cancel previous pending actions and are debounced.
+
+
+ Exception Handling: Exceptions thrown by the action are propagated to the caller.
+ Cancellation (either from the token or disposal) is handled silently without throwing exceptions.
+
+
+ The asynchronous action to invoke after the debounce interval.
+ Optional cancellation token to cancel the debounced action.
+ A task that completes when the action executes or is cancelled/disposed.
+ Thrown when action is null.
+
+
+
+ Cancels any pending debounced action.
+
+
+ This method is thread-safe and can be called concurrently with .
+
+
+
+
+ Updates the debounce interval asynchronously.
+
+
+
+ This method updates the interval without affecting any currently pending debounced action.
+ The new interval will be used for the next debounce operation.
+
+
+ This method is thread-safe and can be called concurrently with .
+
+
+ The new debounce interval in milliseconds. Must be non-negative.
+ Thrown when interval is negative.
+
+
+
+ Updates the debounce interval asynchronously.
+
+
+
+ This method updates the interval without affecting any currently pending debounced action.
+ The new interval will be used for the next debounce operation.
+
+
+ This method is thread-safe and can be called concurrently with .
+
+
+ The new debounce interval as a . Must be non-negative.
+ Thrown when interval is negative.
+
+
+
+ Cancels any pending debounced action asynchronously.
+
+
+ This method is thread-safe and can be called concurrently with .
+
+
+
+
+ Releases all resources used by the .
+
+
+ This method cancels any pending debounced action and prevents further use of the dispatcher.
+ Cancellation is performed synchronously as this is a synchronous Dispose method.
+
+
Indicates that a class should be excluded from automated test discovery and execution.
diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/CodeBeam.MudBlazor.Extensions.Docs.csproj b/docs/CodeBeam.MudBlazor.Extensions.Docs/CodeBeam.MudBlazor.Extensions.Docs.csproj
index 0ac914cd..73cee5f9 100644
--- a/docs/CodeBeam.MudBlazor.Extensions.Docs/CodeBeam.MudBlazor.Extensions.Docs.csproj
+++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/CodeBeam.MudBlazor.Extensions.Docs.csproj
@@ -14,7 +14,7 @@
-
+
diff --git a/src/CodeBeam.MudBlazor.Extensions/Base/MudBaseInputExtended.cs b/src/CodeBeam.MudBlazor.Extensions/Base/MudBaseInputExtended.cs
index 4e853382..a045e5f5 100644
--- a/src/CodeBeam.MudBlazor.Extensions/Base/MudBaseInputExtended.cs
+++ b/src/CodeBeam.MudBlazor.Extensions/Base/MudBaseInputExtended.cs
@@ -94,7 +94,17 @@ protected MudBaseInputExtended()
///
[Parameter]
[Category(CategoryTypes.FormComponent.Behavior)]
- public bool DisablePaste { get; set; }
+ public bool DisablePaste { get; set; } = false;
+
+ ///
+ /// Override to read Value from ParameterState instead of backing field.
+ ///
+ protected internal new T? ReadValue => base.ReadValue;
+
+ ///
+ /// Override to write Value to ParameterState instead of backing field.
+ ///
+ protected internal new string? ReadText => base.ReadText;
///
/// Invokes logic to be executed before input is processed, including raising the BeforeInput event if a
diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/InputExtended/MudDebouncedInputExtended.cs b/src/CodeBeam.MudBlazor.Extensions/Components/InputExtended/MudDebouncedInputExtended.cs
index a766ffb1..0f7e2258 100644
--- a/src/CodeBeam.MudBlazor.Extensions/Components/InputExtended/MudDebouncedInputExtended.cs
+++ b/src/CodeBeam.MudBlazor.Extensions/Components/InputExtended/MudDebouncedInputExtended.cs
@@ -1,68 +1,62 @@
-using System.Threading.Tasks;
-using System.Timers;
-using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components;
using MudBlazor;
+using MudBlazor.State;
+using MudExtensions.Utilities;
namespace MudExtensions
{
///
- ///
+ /// An extended base class for designing input components which update after a delay.
///
- ///
+ /// The type of object managed by this input.
public abstract class MudDebouncedInputExtended : MudBaseInputExtended
{
- private System.Timers.Timer? _timer;
- private double _debounceInterval;
+ private DebounceDispatcher? _debouncer;
///
- /// Interval to be awaited in milliseconds before changing the Text value
+ /// Initializes a new instance of the class.
///
- [Parameter]
- [Category(CategoryTypes.FormComponent.Behavior)]
- public double DebounceInterval
+ protected MudDebouncedInputExtended()
{
- get => _debounceInterval;
- set
- {
- if (DoubleEpsilonEqualityComparer.Default.Equals(_debounceInterval, value))
- return;
- _debounceInterval = value;
- if (_debounceInterval == 0)
- {
- // not debounced, dispose timer if any
- ClearTimer(suppressTick: false);
- return;
- }
- SetTimer();
- }
+ using var registerScope = CreateRegisterScope();
+ registerScope.RegisterParameter(nameof(DebounceInterval))
+ .WithParameter(() => DebounceInterval)
+ .WithComparer(DoubleEpsilonEqualityComparer.Default)
+ .WithChangeHandler(OnDebounceIntervalChangedAsync);
}
+ [Inject]
+ private TimeProvider TimeProvider { get; set; } = null!;
+
///
- /// callback to be called when the debounce interval has elapsed
- /// receives the Text as a parameter
+ /// The number of milliseconds to wait before updating the value.
///
- [Parameter] public EventCallback OnDebounceIntervalElapsed { get; set; }
+ [Parameter, ParameterState(ParameterUsage = ParameterUsageOptions.None)]
+ [Category(CategoryTypes.FormComponent.Behavior)]
+ public double DebounceInterval { get; set; }
///
- ///
+ /// callback to be called when the debounce interval has elapsed
+ /// receives the Text as a parameter
///
- ///
- protected Task OnChanged()
+ [Parameter]
+ public EventCallback OnDebounceIntervalElapsed { get; set; }
+
+ ///
+ protected override Task UpdateTextPropertyAsync(bool updateValue)
{
- if (DebounceInterval > 0 && _timer != null)
- {
- _timer.Stop();
- return base.UpdateValuePropertyAsync(false);
- }
+ // Don't update text if we're debouncing and the value hasn't actually changed
+ var suppressTextUpdate = !updateValue
+ && DebounceInterval > 0
+ && _debouncer is not null
+ && _debouncer.IsPending;
- return Task.CompletedTask;
+ return suppressTextUpdate
+ ? Task.CompletedTask
+ : base.UpdateTextPropertyAsync(updateValue);
}
- ///
- ///
- ///
- ///
- ///
+ ///
protected override Task UpdateValuePropertyAsync(bool updateText)
{
// This method is called when Value property needs to be refreshed from the current Text property, so typically because Text property has changed.
@@ -73,70 +67,105 @@ protected override Task UpdateValuePropertyAsync(bool updateText)
// we have a change coming not from the Text setter, no debouncing is needed
return base.UpdateValuePropertyAsync(updateText);
}
- // if debounce interval is 0 we update immediately
- if (DebounceInterval <= 0 || _timer == null)
+ // if debounce interval is 0 or no debouncer, we update immediately
+ if (DebounceInterval <= 0 || _debouncer is null)
+ {
return base.UpdateValuePropertyAsync(updateText);
- // If a debounce interval is defined, we want to delay the update of Value property.
- _timer.Stop();
- // restart the timer while user is typing
- _timer.Start();
+ }
+
+ // Debounce the update - use fire-and-forget pattern to match the old Timer implementation.
+ _ = _debouncer.DebounceAsync(OnDebouncedUpdate);
return Task.CompletedTask;
}
- ///
- ///
- ///
+ ///
+ protected override async Task ValidateValue()
+ {
+ if (await SynchronizePendingValueForValidationAsync())
+ {
+ return;
+ }
+
+ await base.ValidateValue();
+ }
+
+ ///
protected override void OnParametersSet()
{
base.OnParametersSet();
// if input is to be debounced, makes sense to bind the change of the text to oninput
// so we set Immediate to true
if (DebounceInterval > 0)
+ {
+ // TODO: Don't write to parameter directly
Immediate = true;
+ }
}
- private void SetTimer()
+ private async Task OnDebounceIntervalChangedAsync(ParameterChangedEventArgs args)
{
- if (_timer == null)
+ if (args.Value <= 0)
{
- _timer = new System.Timers.Timer();
- _timer.Elapsed += OnTimerTick;
- _timer.AutoReset = false;
+ // not debounced, dispose debouncer if any
+ _debouncer?.Dispose();
+ _debouncer = null;
+ return;
}
- _timer.Interval = DebounceInterval;
- }
- private void OnTimerTick(object? sender, ElapsedEventArgs e)
- {
- InvokeAsync(OnTimerTickGuiThread).CatchAndLog();
+ // Create debouncer if we don't have one
+ if (_debouncer is null)
+ {
+ _debouncer = new DebounceDispatcher(TimeSpan.FromMilliseconds(args.Value), false, TimeProvider);
+ }
+ else
+ {
+ // Only update interval if it has meaningfully changed
+ // Use DoubleEpsilonEqualityComparer to avoid unnecessary updates due to floating-point precision
+ if (!DoubleEpsilonEqualityComparer.Default.Equals(args.LastValue, args.Value))
+ {
+ await _debouncer.UpdateIntervalAsync(TimeSpan.FromMilliseconds(args.Value));
+ }
+ }
}
- private async Task OnTimerTickGuiThread()
+ private async Task SynchronizePendingValueForValidationAsync()
{
- await base.UpdateValuePropertyAsync(false);
- await OnDebounceIntervalElapsed.InvokeAsync(ReadText);
+ if (DebounceInterval <= 0 || _debouncer is null || !_debouncer.IsPending)
+ {
+ return false;
+ }
+
+ var pendingValue = ConvertGet(ReadText);
+ var pendingValueChanged = !EqualityComparer.Default.Equals(ReadValue, pendingValue);
+
+ await _debouncer.CancelAsync();
+
+ if (!pendingValueChanged)
+ {
+ return false;
+ }
+
+ // SetValueAndUpdateTextAsync already triggers FieldChanged and BeginValidateAsync,
+ // so the synced validation happens there and this call can stop.
+ await SetValueAndUpdateTextAsync(pendingValue, updateText: false);
+ return true;
}
- private void ClearTimer(bool suppressTick = false)
+ private Task OnDebouncedUpdate()
{
- if (_timer == null)
- return;
- var wasEnabled = _timer.Enabled;
- _timer.Stop();
- _timer.Elapsed -= OnTimerTick;
- _timer.Dispose();
- _timer = null;
- if (wasEnabled && !suppressTick)
- OnTimerTickGuiThread().CatchAndLog();
+ return InvokeAsync(async () =>
+ {
+ await base.UpdateValuePropertyAsync(false);
+ await OnDebounceIntervalElapsed.InvokeAsync(ReadText);
+ });
}
- ///
- ///
- ///
+ ///
protected override async ValueTask DisposeAsyncCore()
{
await base.DisposeAsyncCore();
- ClearTimer(suppressTick: true);
+ _debouncer?.Dispose();
+ _debouncer = null;
}
}
}
diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/TextFieldExtended/MudTextFieldExtended.razor b/src/CodeBeam.MudBlazor.Extensions/Components/TextFieldExtended/MudTextFieldExtended.razor
index 0b4c8c9e..8940f217 100644
--- a/src/CodeBeam.MudBlazor.Extensions/Components/TextFieldExtended/MudTextFieldExtended.razor
+++ b/src/CodeBeam.MudBlazor.Extensions/Components/TextFieldExtended/MudTextFieldExtended.razor
@@ -47,7 +47,6 @@
OnInput="@OnInput"
OnBlur="@OnBlurredAsync"
OnKeyDown="@InvokeKeyDownAsync"
- OnInternalInputChanged="OnChanged"
OnKeyUp="@InvokeKeyUpAsync"
OnBeforeInput="@InvokeBeforeInputAsync"
KeyDownPreventDefault="KeyDownPreventDefault"
diff --git a/src/CodeBeam.MudBlazor.Extensions/Utilities/DebounceDispatcher.cs b/src/CodeBeam.MudBlazor.Extensions/Utilities/DebounceDispatcher.cs
new file mode 100644
index 00000000..3bb3ac24
--- /dev/null
+++ b/src/CodeBeam.MudBlazor.Extensions/Utilities/DebounceDispatcher.cs
@@ -0,0 +1,503 @@
+// Copyright (c) MudBlazor 2021
+// MudBlazor licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace MudExtensions.Utilities;
+
+///
+/// Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+///
+///
+///
+/// This dispatcher implements debouncing with optional leading-edge execution.
+/// In trailing mode (default), the action executes only after the specified interval has passed
+/// with no new invocations. In leading mode, the first call executes immediately, then subsequent
+/// calls are debounced.
+///
+///
+/// Thread Safety: This class is thread-safe. Multiple concurrent calls to
+/// are properly synchronized.
+///
+///
+/// Guarantees:
+///
+/// - In trailing mode: Only the last invocation's action will execute after the interval elapses.
+/// - In leading mode: First call executes immediately, subsequent calls within the interval are debounced.
+/// - Previous pending invocations are automatically cancelled.
+/// - Exceptions thrown by the action are propagated to the caller.
+/// - Disposal cancels any pending invocation.
+///
+///
+///
+internal sealed class DebounceDispatcher : IDisposable
+{
+ private bool _disposed;
+ private TimeSpan _interval;
+ private int _pendingOperations;
+ private readonly bool _leading;
+ private readonly TimeProvider _timeProvider;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+ private CancellationTokenSource? _cancellationTokenSource;
+ private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue;
+
+ ///
+ /// Indicates whether a debounce delay is currently pending.
+ ///
+ public bool IsPending => Volatile.Read(ref _pendingOperations) > 0;
+
+ ///
+ /// Initializes a new instance of the class with the specified interval.
+ ///
+ /// The debounce interval in milliseconds. Must be non-negative.
+ /// If true, executes on the leading edge (immediately on first call). Default is false (trailing edge).
+ /// Thrown when interval is negative.
+ public DebounceDispatcher(int interval, bool leading = false)
+ : this(TimeSpan.FromMilliseconds(interval), leading)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified interval.
+ ///
+ /// The debounce interval as a . Must be non-negative.
+ /// If true, executes on the leading edge (immediately on first call). Default is false (trailing edge).
+ /// Thrown when interval is negative.
+ public DebounceDispatcher(TimeSpan interval, bool leading = false)
+ : this(interval, leading, TimeProvider.System)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified interval and time provider.
+ ///
+ /// The debounce interval in milliseconds. Must be non-negative.
+ /// If true, executes on the leading edge (immediately on first call). Default is false (trailing edge).
+ /// The time provider to use for delays and time queries.
+ /// Thrown when TimeProvider is null.
+ /// Thrown when interval is negative.
+ public DebounceDispatcher(int interval, bool leading, TimeProvider timeProvider)
+ : this(TimeSpan.FromMilliseconds(interval), leading, timeProvider)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified interval and time provider.
+ ///
+ /// The debounce interval as a . Must be non-negative.
+ /// If true, executes on the leading edge (immediately on first call). Default is false (trailing edge).
+ /// The time provider to use for delays and time queries.
+ /// Thrown when TimeProvider is null.
+ /// Thrown when interval is negative.
+ public DebounceDispatcher(TimeSpan interval, bool leading, TimeProvider timeProvider)
+ {
+ ArgumentNullException.ThrowIfNull(timeProvider);
+ if (interval < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(interval), @"Interval must be non-negative.");
+ }
+
+ _interval = interval;
+ _leading = leading;
+ _timeProvider = timeProvider;
+ }
+
+ ///
+ /// Debounces the execution of an asynchronous action.
+ ///
+ ///
+ ///
+ /// In trailing mode (default): Each call cancels any previously pending action and starts a new timer.
+ /// The action executes only if no new calls occur within the configured interval.
+ ///
+ ///
+ /// In leading mode: The first call (or first call after the interval expires) executes immediately.
+ /// Subsequent calls within the interval cancel previous pending actions and are debounced.
+ ///
+ ///
+ /// Exception Handling: Exceptions thrown by the action are propagated to the caller.
+ /// Cancellation (either from the token or disposal) is handled silently without throwing exceptions.
+ ///
+ ///
+ /// The asynchronous action to invoke after the debounce interval.
+ /// Optional cancellation token to cancel the debounced action.
+ /// A task that completes when the action executes or is cancelled/disposed.
+ /// Thrown when action is null.
+ public async Task DebounceAsync(Func action, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(action);
+
+ if (Volatile.Read(ref _disposed) || cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ var executeImmediately = false;
+ CancellationTokenSource? localCts = null;
+ CancellationTokenSource? previousCts = null;
+ var scheduledInterval = TimeSpan.Zero;
+ var lockAcquired = false;
+
+ try
+ {
+ await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ lockAcquired = true;
+
+ // Check again after acquiring lock
+ if (_disposed)
+ {
+ return;
+ }
+
+ // In leading mode, check if we should execute immediately
+ if (_leading)
+ {
+ var now = _timeProvider.GetUtcNow();
+ var timeSinceLastExecution = now - _lastExecutionTime;
+
+ // Execute immediately if enough time has passed since last execution
+ if (timeSinceLastExecution >= _interval)
+ {
+ executeImmediately = true;
+ _lastExecutionTime = now;
+ }
+ }
+
+ // Replace the current pending CTS if we're not executing immediately.
+ if (!executeImmediately)
+ {
+ scheduledInterval = _interval;
+ previousCts = _cancellationTokenSource;
+ localCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ _cancellationTokenSource = localCts;
+ }
+ }
+ catch (Exception ex) when (IsExpectedDebounceFlowException(ex))
+ {
+ // Lock-dispose and cancellation races are expected in debounce control flow.
+ return;
+ }
+ finally
+ {
+ if (lockAcquired)
+ {
+ _lock.Release();
+ }
+ }
+
+ await SafeCancelAsync(previousCts).ConfigureAwait(false);
+
+ if (executeImmediately)
+ {
+ // Execute immediately without delay
+ await action().ConfigureAwait(false);
+ return;
+ }
+
+ if (localCts is not { } scheduledCts)
+ {
+ return;
+ }
+
+ Interlocked.Increment(ref _pendingOperations);
+ var proceedToExecution = false;
+ try
+ {
+ CancellationToken delayToken;
+ try
+ {
+ delayToken = scheduledCts.Token;
+ }
+ catch (ObjectDisposedException)
+ {
+ return;
+ }
+
+ // Wait for the debounce interval
+ await Task.Delay(scheduledInterval, _timeProvider, delayToken).ConfigureAwait(false);
+ proceedToExecution = true;
+ }
+ catch (Exception ex) when (IsExpectedDebounceFlowException(ex))
+ {
+ // Cancellation/disposal races are expected while waiting the debounce delay.
+ return;
+ }
+ finally
+ {
+ Interlocked.Decrement(ref _pendingOperations);
+ if (!proceedToExecution)
+ {
+ scheduledCts.Dispose();
+ }
+ }
+
+ try
+ {
+ // Update last execution time for leading mode
+ if (_leading)
+ {
+ var leadingLockAcquired = false;
+ try
+ {
+ await _lock.WaitAsync(scheduledCts.Token).ConfigureAwait(false);
+ leadingLockAcquired = true;
+ _lastExecutionTime = _timeProvider.GetUtcNow();
+ }
+ finally
+ {
+ if (leadingLockAcquired)
+ {
+ _lock.Release();
+ }
+ }
+ }
+
+ // Execute the action
+ await action().ConfigureAwait(false);
+ }
+ catch (Exception ex) when (IsExpectedDebounceFlowException(ex))
+ {
+ // Cancellation/disposal races are expected around execution handoff.
+ }
+ finally
+ {
+ var cleanupLockAcquired = false;
+ try
+ {
+ await _lock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ cleanupLockAcquired = true;
+ if (ReferenceEquals(_cancellationTokenSource, scheduledCts))
+ {
+ _cancellationTokenSource = null;
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ // Ignore races with disposal.
+ }
+ finally
+ {
+ if (cleanupLockAcquired)
+ {
+ _lock.Release();
+ }
+
+ scheduledCts.Dispose();
+ }
+ }
+ }
+
+ ///
+ /// Cancels any pending debounced action.
+ ///
+ ///
+ /// This method is thread-safe and can be called concurrently with .
+ ///
+ public void Cancel()
+ {
+ CancellationTokenSource? ctsToCancel;
+ if (OperatingSystem.IsBrowser())
+ {
+ ctsToCancel = _cancellationTokenSource;
+ _cancellationTokenSource = null;
+ }
+ else
+ {
+ // ReSharper disable once MethodSupportsCancellation
+ _lock.Wait();
+ try
+ {
+ ctsToCancel = _cancellationTokenSource;
+ _cancellationTokenSource = null;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ CancelAndDispose(ctsToCancel);
+ }
+
+ ///
+ /// Updates the debounce interval asynchronously.
+ ///
+ ///
+ ///
+ /// This method updates the interval without affecting any currently pending debounced action.
+ /// The new interval will be used for the next debounce operation.
+ ///
+ ///
+ /// This method is thread-safe and can be called concurrently with .
+ ///
+ ///
+ /// The new debounce interval in milliseconds. Must be non-negative.
+ /// Thrown when interval is negative.
+ public Task UpdateIntervalAsync(int interval) => UpdateIntervalAsync(TimeSpan.FromMilliseconds(interval));
+
+ ///
+ /// Updates the debounce interval asynchronously.
+ ///
+ ///
+ ///
+ /// This method updates the interval without affecting any currently pending debounced action.
+ /// The new interval will be used for the next debounce operation.
+ ///
+ ///
+ /// This method is thread-safe and can be called concurrently with .
+ ///
+ ///
+ /// The new debounce interval as a . Must be non-negative.
+ /// Thrown when interval is negative.
+ public async Task UpdateIntervalAsync(TimeSpan interval)
+ {
+ if (interval < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(interval), @"Interval must be non-negative.");
+ }
+
+ await _lock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ _interval = interval;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ ///
+ /// Cancels any pending debounced action asynchronously.
+ ///
+ ///
+ /// This method is thread-safe and can be called concurrently with .
+ ///
+ public async Task CancelAsync()
+ {
+ CancellationTokenSource? ctsToCancel;
+ await _lock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ ctsToCancel = _cancellationTokenSource;
+ _cancellationTokenSource = null;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+
+ await CancelAndDisposeAsync(ctsToCancel).ConfigureAwait(false);
+ }
+
+ ///
+ /// Releases all resources used by the .
+ ///
+ ///
+ /// This method cancels any pending debounced action and prevents further use of the dispatcher.
+ /// Cancellation is performed synchronously as this is a synchronous Dispose method.
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ CancellationTokenSource? ctsToCancel;
+ // ReSharper disable once MethodSupportsCancellation
+ if (OperatingSystem.IsBrowser())
+ {
+ _disposed = true;
+ ctsToCancel = _cancellationTokenSource;
+ _cancellationTokenSource = null;
+ }
+ else
+ {
+ _lock.Wait();
+ try
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ ctsToCancel = _cancellationTokenSource;
+ _cancellationTokenSource = null;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ CancelAndDispose(ctsToCancel);
+
+ // Intentionally do not dispose _lock. DebounceAsync/Cancel/UpdateIntervalAsync may still be racing and
+ // disposing SemaphoreSlim while waiters exist can surface hangs/ObjectDisposedException paths.
+ }
+
+ private static void SafeCancel(CancellationTokenSource? cancellationTokenSource)
+ {
+ if (cancellationTokenSource is null)
+ {
+ return;
+ }
+
+ try
+ {
+ cancellationTokenSource.Cancel();
+ }
+ catch (Exception ex) when (IsExpectedCancellationException(ex))
+ {
+ // Ignore cancellation callback/disposal race exceptions.
+ }
+ }
+
+ private static void CancelAndDispose(CancellationTokenSource? cancellationTokenSource)
+ {
+ SafeCancel(cancellationTokenSource);
+ DisposeSafely(cancellationTokenSource);
+ }
+
+ private static async Task SafeCancelAsync(CancellationTokenSource? cancellationTokenSource)
+ {
+ if (cancellationTokenSource is null)
+ {
+ return;
+ }
+
+ try
+ {
+ await cancellationTokenSource.CancelAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex) when (IsExpectedCancellationException(ex))
+ {
+ // Ignore cancellation callback/disposal race exceptions.
+ }
+ }
+
+ private static async Task CancelAndDisposeAsync(CancellationTokenSource? cancellationTokenSource)
+ {
+ await SafeCancelAsync(cancellationTokenSource).ConfigureAwait(false);
+ DisposeSafely(cancellationTokenSource);
+ }
+
+ private static void DisposeSafely(CancellationTokenSource? cancellationTokenSource)
+ {
+ try
+ {
+ cancellationTokenSource?.Dispose();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Ignore races with disposal.
+ }
+ }
+
+ private static bool IsExpectedCancellationException(Exception exception) =>
+ exception is ObjectDisposedException or AggregateException;
+
+ private static bool IsExpectedDebounceFlowException(Exception exception) =>
+ exception is ObjectDisposedException or OperationCanceledException;
+}
diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/Pages/Index.razor b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/Pages/Index.razor
index 1b77fa38..e41859b7 100644
--- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/Pages/Index.razor
+++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/Pages/Index.razor
@@ -7,3 +7,5 @@
Welcome to your new app.
+
+
diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/TextFieldExtended/DebounceTextFieldTest.razor b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/TextFieldExtended/DebounceTextFieldTest.razor
new file mode 100644
index 00000000..3fbe75cb
--- /dev/null
+++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/TextFieldExtended/DebounceTextFieldTest.razor
@@ -0,0 +1,21 @@
+@namespace MudExtensions.UnitTests.TestComponents
+
+
+
+
+
+@value
+
+@code {
+
+ public static string __description__ = "Multi-Select Required Should Recognize Values";
+
+ string value;
+}
\ No newline at end of file
diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/TextFieldExtended/DebouncedTextFieldAsyncInitializationSyncTest.razor b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/TextFieldExtended/DebouncedTextFieldAsyncInitializationSyncTest.razor
new file mode 100644
index 00000000..b19f2909
--- /dev/null
+++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/TextFieldExtended/DebouncedTextFieldAsyncInitializationSyncTest.razor
@@ -0,0 +1,21 @@
+@namespace MudExtensions.UnitTests.TestComponents
+
+
+
+
+
+@code {
+ private string? _value = "i";
+
+ protected override async Task OnInitializedAsync()
+ {
+ await Task.Yield();
+ _value = "init value";
+ await InvokeAsync(StateHasChanged);
+ }
+}
diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/CodeBeam.MudBlazor.Extensions.UnitTests.csproj b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/CodeBeam.MudBlazor.Extensions.UnitTests.csproj
index 9e88fbf5..668a084d 100644
--- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/CodeBeam.MudBlazor.Extensions.UnitTests.csproj
+++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/CodeBeam.MudBlazor.Extensions.UnitTests.csproj
@@ -12,6 +12,7 @@
+
diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/DebouncedInputExtendedTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/DebouncedInputExtendedTests.cs
new file mode 100644
index 00000000..74396a78
--- /dev/null
+++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/DebouncedInputExtendedTests.cs
@@ -0,0 +1,106 @@
+using AwesomeAssertions;
+using Bunit;
+using Microsoft.AspNetCore.Components;
+using MudExtensions.UnitTests.Extensions;
+using MudExtensions.UnitTests.TestComponents;
+
+namespace MudExtensions.UnitTests.Components;
+
+[TestFixture]
+public class DebouncedInputExtendedTests : BunitTest
+{
+ ///
+ /// If Debounce Interval is null or 0, Value should change immediately
+ ///
+ [Test]
+ public async Task WithNoDebounceIntervalValueShouldChangeImmediately()
+ {
+ //no interval passed, so, by default is 0
+ // We pass the Immediate parameter set to true, in order to bind to oninput
+ var comp = Context.Render>(parameters => parameters.Add(p => p.Immediate, true));
+ var textField = comp.Instance;
+ var input = comp.Find("input");
+
+ //Act
+ await input.InputAsync(new ChangeEventArgs() { Value = "Some Value" });
+
+ //Assert
+ //input value has changed, DebounceInterval is 0, so Value should change in TextField immediately
+ textField.ReadValue.Should().Be("Some Value");
+ }
+
+ ///
+ /// Value should not change immediately. Should respect the Debounce Interval
+ ///
+ [Test]
+ public async Task ShouldRespectDebounceIntervalPropertyInTextField()
+ {
+ var comp = Context.Render>(parameters => parameters.Add(p => p.DebounceInterval, 200d));
+ var textField = comp.Instance;
+ var input = comp.Find("input");
+
+ //Act
+ await input.InputAsync(new ChangeEventArgs() { Value = "Some Value" });
+
+ //Assert
+ //if DebounceInterval is set, Immediate should be true by default
+ textField.Immediate.Should().BeTrue();
+
+ //input value has changed, but elapsed time is 0, so Value should not change in TextField
+ textField.ReadValue.Should().BeNull();
+
+ //DebounceInterval is 200 ms, so at 100 ms Value should not change in TextField
+ await Task.Delay(100);
+ textField.ReadValue.Should().BeNull();
+
+ //More than 200 ms had elapsed, so Value should be updated
+ await comp.WaitForAssertionAsync(() => textField.ReadValue.Should().Be("Some Value"));
+ }
+
+ ///
+ /// DebounceInterval updates with epsilon-equivalent values should not break debouncing
+ ///
+ [Test]
+ public async Task DebounceInterval_EpsilonEquivalentValues_PreservesDebounce()
+ {
+ // Arrange
+ var comp = Context.Render>(parameters => parameters.Add(p => p.DebounceInterval, 200.0));
+ var textField = comp.Instance;
+ var input = comp.Find("input");
+
+ // Act - Input a value
+ await input.InputAsync(new ChangeEventArgs() { Value = "Test Value" });
+
+ // Change DebounceInterval to an epsilon-equivalent value (should not reset debouncer)
+ await comp.SetParametersAndRenderAsync(parameters => parameters.Add(p => p.DebounceInterval, 200.0000001));
+
+ // Assert - Value should still be null (debounce still pending)
+ textField.ReadValue.Should().BeNull();
+
+ // Wait for the debounce to complete
+ await comp.WaitForAssertionAsync(() => textField.ReadValue.Should().Be("Test Value"));
+ }
+
+ [Test]
+ public async Task DebouncedTextField_ShouldStayInSyncWithBoundValueAfterAsyncInitialization()
+ {
+ var comp = Context.Render();
+
+ await comp.WaitForAssertionAsync(() =>
+ {
+ var inputs = comp.FindAll("input");
+ inputs[0].GetAttribute("value").Should().Be("init value");
+ inputs[1].GetAttribute("value").Should().Be("init value");
+ });
+
+ var immediateInput = comp.FindAll("input")[1];
+ await immediateInput.ChangeAsync(new ChangeEventArgs { Value = "changed value" });
+
+ await comp.WaitForAssertionAsync(() =>
+ {
+ var inputs = comp.FindAll("input");
+ inputs[0].GetAttribute("value").Should().Be("changed value");
+ inputs[1].GetAttribute("value").Should().Be("changed value");
+ }, TimeSpan.FromSeconds(1));
+ }
+}
diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Utilities/DebounceDispatcherTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Utilities/DebounceDispatcherTests.cs
new file mode 100644
index 00000000..c553472e
--- /dev/null
+++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Utilities/DebounceDispatcherTests.cs
@@ -0,0 +1,992 @@
+// Copyright (c) MudBlazor 2021
+// MudBlazor licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Reflection;
+using AwesomeAssertions;
+using Microsoft.Extensions.Time.Testing;
+using MudExtensions.Utilities;
+using NUnit.Framework;
+
+namespace MudExtensions.UnitTests.Utilities;
+
+#nullable enable
+[TestFixture]
+public class DebounceDispatcherTests
+{
+ [Test]
+ public async Task DebounceAsync_MultipleCallsWithinInterval_ExecutesOnce()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(100);
+ var counter = 0;
+ Task Invoke()
+ {
+ counter++;
+
+ return Task.CompletedTask;
+ }
+
+ // Act
+ var task1 = debounceDispatcher.DebounceAsync(Invoke);
+ var task2 = debounceDispatcher.DebounceAsync(Invoke);
+ var task3 = debounceDispatcher.DebounceAsync(Invoke);
+
+ // Wait for all tasks - first two should complete silently (cancelled internally)
+ await task1;
+ await task2;
+ await task3; // Last one should succeed
+
+ // Assert
+ counter.Should().Be(1);
+ }
+
+ [Test]
+ public async Task DebounceAsync_MultipleCallsOutsideInterval_ExecutesMultipleTimes()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(100, false, timeProvider);
+ var counter = 0;
+ Task Invoke()
+ {
+ counter++;
+
+ return Task.CompletedTask;
+ }
+
+ // Act & Assert
+ var task1 = debounceDispatcher.DebounceAsync(Invoke);
+ timeProvider.Advance(TimeSpan.FromMilliseconds(150));
+ await task1;
+ counter.Should().Be(1);
+
+ var task2 = debounceDispatcher.DebounceAsync(Invoke);
+ timeProvider.Advance(TimeSpan.FromMilliseconds(150));
+ await task2;
+ counter.Should().Be(2);
+
+ var task3 = debounceDispatcher.DebounceAsync(Invoke);
+ timeProvider.Advance(TimeSpan.FromMilliseconds(150));
+ await task3;
+ counter.Should().Be(3);
+ }
+
+ [Test]
+ public async Task DebounceAsync_SingleCall_ExecutesAfterInterval()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(100, false, timeProvider);
+ var executed = false;
+ Task Invoke()
+ {
+ executed = true;
+ return Task.CompletedTask;
+ }
+
+ // Act
+ var task = debounceDispatcher.DebounceAsync(Invoke);
+ executed.Should().BeFalse();
+ timeProvider.Advance(TimeSpan.FromMilliseconds(100));
+ await task;
+
+ // Assert
+ executed.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task DebounceAsync_ZeroInterval_ExecutesImmediately()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(0);
+ var executed = false;
+ Task Invoke()
+ {
+ executed = true;
+ return Task.CompletedTask;
+ }
+
+ // Act
+ await debounceDispatcher.DebounceAsync(Invoke);
+
+ // Assert
+ executed.Should().BeTrue();
+ }
+
+ [Test]
+ public void DebounceAsync_ExceptionInAction_PropagatesException()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(50, false, timeProvider);
+ Task ThrowingAction()
+ {
+ throw new InvalidOperationException("Test exception");
+ }
+
+ // Act & Assert
+ var exception = Assert.ThrowsAsync(
+ async () =>
+ {
+ var task = debounceDispatcher.DebounceAsync(ThrowingAction);
+ timeProvider.Advance(TimeSpan.FromMilliseconds(50));
+ await task;
+ });
+ exception!.Message.Should().Be("Test exception");
+ }
+
+ [Test]
+ public async Task DebounceAsync_CancellationToken_CancelsOperation()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(1000);
+ using var cts = new CancellationTokenSource();
+ var executed = false;
+ Task Invoke()
+ {
+ executed = true;
+ return Task.CompletedTask;
+ }
+
+ // Act
+ var task = debounceDispatcher.DebounceAsync(Invoke, cts.Token);
+ // ReSharper disable once MethodHasAsyncOverload
+ cts.Cancel();
+
+ // Assert - should complete silently without throwing
+ await task;
+ executed.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task DebounceAsync_CancelMethod_CancelsPendingOperation()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(1000);
+ var executed = false;
+ Task Invoke()
+ {
+ executed = true;
+ return Task.CompletedTask;
+ }
+
+ // Act
+ var task = debounceDispatcher.DebounceAsync(Invoke);
+ // ReSharper disable once MethodHasAsyncOverload
+ debounceDispatcher.Cancel();
+
+ // Assert - should complete silently without throwing
+ await task;
+ executed.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task DebounceAsync_CancelAsyncMethod_CancelsPendingOperation()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(1000);
+ var executed = false;
+ Task Invoke()
+ {
+ executed = true;
+ return Task.CompletedTask;
+ }
+
+ // Act
+ var task = debounceDispatcher.DebounceAsync(Invoke);
+ await debounceDispatcher.CancelAsync();
+
+ // Assert - should complete silently without throwing
+ await task;
+ executed.Should().BeFalse();
+ }
+
+ [Test]
+ public void DebounceAsync_Dispose_PreventsNewCalls()
+ {
+ // Arrange
+ var debounceDispatcher = new DebounceDispatcher(100);
+ Task Invoke() => Task.CompletedTask;
+
+ // Act
+ debounceDispatcher.Dispose();
+
+ // Assert - should complete silently without throwing
+ var task = debounceDispatcher.DebounceAsync(Invoke);
+ task.IsCompleted.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task DebounceAsync_Dispose_CancelsPendingOperation()
+ {
+ // Arrange
+ var debounceDispatcher = new DebounceDispatcher(1000);
+ var executed = false;
+ Task Invoke()
+ {
+ executed = true;
+ return Task.CompletedTask;
+ }
+
+ // Act
+ var task = debounceDispatcher.DebounceAsync(Invoke);
+ debounceDispatcher.Dispose();
+
+ // Assert - should complete silently without throwing
+ await task.WaitAsync(TimeSpan.FromSeconds(5));
+ executed.Should().BeFalse();
+ }
+
+ [Test]
+ public void DebounceAsync_DoubleDispose_DoesNotThrow()
+ {
+ // Arrange
+ var debounceDispatcher = new DebounceDispatcher(100);
+
+ // Act - Dispose twice
+ debounceDispatcher.Dispose();
+ debounceDispatcher.Dispose();
+
+ // Assert - Should not throw, just pass if we get here
+ Assert.Pass();
+ }
+
+ [Test]
+ public async Task DebounceAsync_ExternalCancellationDuringDebounce_CancelsCorrectly()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(200, false, timeProvider);
+ using var cts = new CancellationTokenSource();
+ var executed = false;
+
+ Task Invoke()
+ {
+ executed = true;
+ return Task.CompletedTask;
+ }
+
+ // Act - Start debounce with external cancellation token
+ var task = debounceDispatcher.DebounceAsync(Invoke, cts.Token);
+
+ // Cancel the external token while debounce is pending.
+ timeProvider.Advance(TimeSpan.FromMilliseconds(50));
+ // ReSharper disable once MethodHasAsyncOverload
+ cts.Cancel();
+
+ // Advance enough time so the debounce would have run if not cancelled.
+ timeProvider.Advance(TimeSpan.FromMilliseconds(200));
+ await task;
+
+ // Assert - Should not have executed due to cancellation
+ executed.Should().BeFalse();
+ task.IsCompleted.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task DebounceAsync_LeadingMode_ExternalCancellationAfterImmediate_DoesNotAffectExecution()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(200, leading: true, timeProvider);
+ using var cts = new CancellationTokenSource();
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - First call executes immediately
+ await debounceDispatcher.DebounceAsync(TrackingAction, cts.Token);
+ executionCount.Should().Be(1);
+
+ // Start another debounce with same token
+ var task = debounceDispatcher.DebounceAsync(TrackingAction, cts.Token);
+
+ // Cancel the token during the debounce wait
+ timeProvider.Advance(TimeSpan.FromMilliseconds(50));
+ // ReSharper disable once MethodHasAsyncOverload
+ cts.Cancel();
+
+ // Advance enough time so the debounce would have run if not cancelled.
+ timeProvider.Advance(TimeSpan.FromMilliseconds(200));
+ await task;
+
+ // Assert - Second call should not have executed due to cancellation
+ executionCount.Should().Be(1);
+ task.IsCompleted.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task DebounceAsync_RapidCalls_OnlyLastExecutes()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(100);
+ var executionOrder = new List();
+
+ Func CreateAction(int id) => () =>
+ {
+ executionOrder.Add(id);
+ return Task.CompletedTask;
+ };
+
+ // Act - Fire 10 rapid calls
+ var tasks = new List();
+ for (var i = 0; i < 10; i++)
+ {
+ tasks.Add(debounceDispatcher.DebounceAsync(CreateAction(i)));
+ }
+
+ // Wait for the last one
+ await tasks[9];
+
+ // Assert - Only the last action (id=9) should have executed
+ executionOrder.Should().ContainSingle();
+ executionOrder[0].Should().Be(9);
+ }
+
+ [Test]
+ public async Task DebounceAsync_ConcurrentCalls_ThreadSafe()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(50);
+ var executionCount = 0;
+ Task Invoke()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - Fire many concurrent calls
+ var tasks = Enumerable.Range(0, 100)
+ .Select(_ => Task.Run(async () =>
+ {
+ // ReSharper disable once AccessToDisposedClosure
+ await debounceDispatcher.DebounceAsync(Invoke);
+ }))
+ .ToArray();
+
+ await Task.WhenAll(tasks);
+
+ // Give time for last debounce to complete
+ await Task.Delay(100);
+
+ // Assert - Should execute at least once, but may execute a few times due to timing
+ executionCount.Should().BeGreaterThanOrEqualTo(1);
+ executionCount.Should().BeLessThan(10); // But not too many times
+ }
+
+ [Test]
+ public void Constructor_NegativeInterval_ThrowsArgumentOutOfRangeException()
+ {
+ // Act & Assert
+ Assert.Throws(() => _ = new DebounceDispatcher(-100));
+ Assert.Throws(() => _ = new DebounceDispatcher(TimeSpan.FromMilliseconds(-100)));
+ }
+
+ [Test]
+ public void DebounceAsync_NullAction_ThrowsArgumentNullException()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(100);
+
+ // Act & Assert
+ Assert.ThrowsAsync(
+ async () => await debounceDispatcher.DebounceAsync(null!));
+ }
+
+ [Test]
+ public async Task DebounceAsync_LongRunningAction_DoesNotBlockSubsequentCalls()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(50, false, timeProvider);
+ var firstStarted = new TaskCompletionSource();
+ var firstCanComplete = new TaskCompletionSource();
+
+ async Task LongRunningAction()
+ {
+ firstStarted.SetResult(true);
+ await firstCanComplete.Task;
+ }
+
+ Task QuickAction() => Task.CompletedTask;
+
+ // Act
+ var firstTask = debounceDispatcher.DebounceAsync(LongRunningAction);
+ timeProvider.Advance(TimeSpan.FromMilliseconds(50));
+ await firstStarted.Task; // Wait for first action to start
+
+ // Allow first to complete
+ firstCanComplete.SetResult(true);
+ await firstTask;
+
+ // Now start a new debounce - should work fine
+ var secondTask = debounceDispatcher.DebounceAsync(QuickAction);
+ timeProvider.Advance(TimeSpan.FromMilliseconds(50));
+ await secondTask;
+
+ // Assert - If we got here, it worked
+ Assert.Pass();
+ }
+
+ [Test]
+ public async Task DebounceAsync_LeadingMode_ExecutesImmediatelyOnFirstCall()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(100, leading: true, timeProvider);
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act
+ await debounceDispatcher.DebounceAsync(TrackingAction);
+
+ // Assert - First call should execute immediately
+ executionCount.Should().Be(1);
+ }
+
+ [Test]
+ public async Task DebounceAsync_LeadingMode_DebounceSubsequentCalls()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(100, leading: true, timeProvider);
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - First call executes immediately
+ await debounceDispatcher.DebounceAsync(TrackingAction);
+ executionCount.Should().Be(1);
+
+ // Rapid subsequent calls within interval should be debounced
+ var task1 = debounceDispatcher.DebounceAsync(TrackingAction);
+ var task2 = debounceDispatcher.DebounceAsync(TrackingAction);
+ var task3 = debounceDispatcher.DebounceAsync(TrackingAction);
+
+ timeProvider.Advance(TimeSpan.FromMilliseconds(100));
+
+ // Wait for all debounced tasks to complete
+ await task1;
+ await task2;
+ await task3;
+
+ // Assert - Should have executed twice (first immediate, last after debounce)
+ executionCount.Should().Be(2);
+ }
+
+ [Test]
+ public async Task UpdateInterval_ChangesDebounceInterval()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(1000, false, timeProvider);
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - Update interval before any debounce
+ await debounceDispatcher.UpdateIntervalAsync(100);
+
+ // Start debounce with new interval
+ var task = debounceDispatcher.DebounceAsync(TrackingAction);
+
+ timeProvider.Advance(TimeSpan.FromMilliseconds(150));
+ await task;
+
+ // Assert - Should have executed with the new interval
+ executionCount.Should().Be(1);
+ }
+
+ [Test]
+ public async Task UpdateInterval_PreservesPendingDebounce()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(200, false, timeProvider);
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - Start debounce
+ var task = debounceDispatcher.DebounceAsync(TrackingAction);
+
+ // Update interval while debounce is pending (doesn't cancel the pending debounce)
+ timeProvider.Advance(TimeSpan.FromMilliseconds(50));
+ await debounceDispatcher.UpdateIntervalAsync(300);
+
+ // Wait for original interval to complete
+ timeProvider.Advance(TimeSpan.FromMilliseconds(200));
+ await task;
+
+ // Assert - Should have executed with original interval since update doesn't cancel pending
+ executionCount.Should().Be(1);
+ }
+
+ [Test]
+ public void UpdateInterval_NegativeInterval_ThrowsArgumentOutOfRangeException()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(100);
+
+ // Act & Assert
+ Assert.ThrowsAsync(async () => await debounceDispatcher.UpdateIntervalAsync(-100));
+ Assert.ThrowsAsync(async () => await debounceDispatcher.UpdateIntervalAsync(TimeSpan.FromMilliseconds(-100)));
+ }
+
+ [Test]
+ public async Task UpdateInterval_ToZero_AllowsImmediateExecution()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(1000);
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - Update to zero interval
+ await debounceDispatcher.UpdateIntervalAsync(0);
+
+ // Debounce should execute immediately with zero interval
+ await debounceDispatcher.DebounceAsync(TrackingAction);
+
+ // Assert
+ executionCount.Should().Be(1);
+ }
+
+ [Test]
+ public async Task UpdateInterval_MultipleUpdates_UsesLatestInterval()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(1000, false, timeProvider);
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - Update interval multiple times
+ await debounceDispatcher.UpdateIntervalAsync(500);
+ await debounceDispatcher.UpdateIntervalAsync(200);
+ await debounceDispatcher.UpdateIntervalAsync(100);
+
+ // Start debounce
+ var task = debounceDispatcher.DebounceAsync(TrackingAction);
+
+ // Wait for the final interval
+ timeProvider.Advance(TimeSpan.FromMilliseconds(150));
+ await task;
+
+ // Assert - Should use latest interval (100ms)
+ executionCount.Should().Be(1);
+ }
+
+ [Test]
+ public async Task UpdateInterval_ConcurrentWithDebounce_ThreadSafe()
+ {
+ // Arrange
+ using var debounceDispatcher = new DebounceDispatcher(100);
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - Update interval concurrently with debounce calls
+ var tasks = new List();
+ for (var i = 0; i < 10; i++)
+ {
+ tasks.Add(Task.Run(async () =>
+ {
+ // ReSharper disable AccessToDisposedClosure
+ await debounceDispatcher.DebounceAsync(TrackingAction);
+ }));
+
+ var i1 = i;
+ tasks.Add(Task.Run(async () =>
+ {
+ await debounceDispatcher.UpdateIntervalAsync(100 + (i1 * 10));
+ // ReSharper restore AccessToDisposedClosure
+ }));
+ }
+
+ await Task.WhenAll(tasks);
+ await Task.Delay(300); // Wait for final debounce
+
+ // Assert - Should have executed at least once without crashing
+ executionCount.Should().BeGreaterThanOrEqualTo(1);
+ }
+
+ [Test]
+ public async Task UpdateInterval_WithLeadingMode_UsesNewIntervalForSubsequentCalls()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(1000, leading: true, timeProvider);
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - First call executes immediately
+ await debounceDispatcher.DebounceAsync(TrackingAction);
+ executionCount.Should().Be(1);
+
+ // Update to shorter interval
+ await debounceDispatcher.UpdateIntervalAsync(100);
+
+ // Wait for new interval to pass
+ timeProvider.Advance(TimeSpan.FromMilliseconds(150));
+
+ // Next call should execute immediately with new interval
+ await debounceDispatcher.DebounceAsync(TrackingAction);
+
+ // Assert
+ executionCount.Should().Be(2);
+ }
+
+ [Test]
+ public async Task UpdateInterval_FromTimeSpan_WorksCorrectly()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(TimeSpan.FromSeconds(10), false, timeProvider);
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - Update using TimeSpan
+ await debounceDispatcher.UpdateIntervalAsync(TimeSpan.FromMilliseconds(100));
+
+ var task = debounceDispatcher.DebounceAsync(TrackingAction);
+ timeProvider.Advance(TimeSpan.FromMilliseconds(150));
+ await task;
+
+ // Assert
+ executionCount.Should().Be(1);
+ }
+
+ [Test]
+ public async Task DebounceAsync_LeadingMode_ResetsAfterInterval()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(100, leading: true, timeProvider: timeProvider);
+ var executionCount = 0;
+
+ Task TrackingAction()
+ {
+ Interlocked.Increment(ref executionCount);
+ return Task.CompletedTask;
+ }
+
+ // Act - First call executes immediately
+ var task1 = debounceDispatcher.DebounceAsync(TrackingAction);
+ await task1;
+ executionCount.Should().Be(1);
+
+ // Wait for interval to pass
+ timeProvider.Advance(TimeSpan.FromMilliseconds(150));
+
+ // Next call should execute immediately again
+ var task2 = debounceDispatcher.DebounceAsync(TrackingAction);
+ await task2;
+
+ // Assert
+ executionCount.Should().Be(2);
+ }
+
+ [Test]
+ public async Task DebounceAsync_IsPending_TracksDelayWindowOnly()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(100, false, timeProvider);
+ var actionGate = new TaskCompletionSource();
+
+ async Task BlockingAction() => await actionGate.Task;
+
+ // Act
+ var task = debounceDispatcher.DebounceAsync(BlockingAction);
+ await Task.Yield();
+
+ // Assert - pending during delay
+ debounceDispatcher.IsPending.Should().BeTrue();
+
+ // advance debounce interval to begin action execution
+ timeProvider.Advance(TimeSpan.FromMilliseconds(100));
+ var pendingCleared = await WaitUntilAsync(() => !debounceDispatcher.IsPending, TimeSpan.FromSeconds(1));
+
+ // Assert - pending cleared once delay elapses, even while action is still running
+ pendingCleared.Should().BeTrue();
+ debounceDispatcher.IsPending.Should().BeFalse();
+
+ actionGate.SetResult(true);
+ await task;
+ debounceDispatcher.IsPending.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task DebounceAsync_IsPending_ClearsAfterCancellation()
+ {
+ // Arrange
+ var timeProvider = new FakeTimeProvider();
+ using var debounceDispatcher = new DebounceDispatcher(100, false, timeProvider);
+
+ // Act
+ var task = debounceDispatcher.DebounceAsync(() => Task.CompletedTask);
+ await Task.Yield();
+ debounceDispatcher.IsPending.Should().BeTrue();
+
+ await debounceDispatcher.CancelAsync();
+ await task;
+
+ // Assert
+ debounceDispatcher.IsPending.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task Cancel_Swallows_AggregateException_From_Callbacks()
+ {
+ var dispatcher = new DebounceDispatcher(TimeSpan.FromMilliseconds(200));
+
+ // create CTS by starting a debounce
+ _ = dispatcher.DebounceAsync(() => Task.CompletedTask);
+
+ var cts = await WaitForPrivateCtsAsync(dispatcher);
+
+ // register a callback that throws when Cancel() is called
+ cts.Token.Register(() => throw new InvalidOperationException("callback fail"));
+
+ // Cancel should swallow exceptions
+ var act = () => dispatcher.Cancel();
+
+ act.Should().NotThrow();
+ }
+
+ [Test]
+ public async Task Cancel_Swallows_ObjectDisposedException_When_CtsDisposed()
+ {
+ var dispatcher = new DebounceDispatcher(TimeSpan.FromMilliseconds(200));
+
+ _ = dispatcher.DebounceAsync(() => Task.CompletedTask);
+ var cts = await WaitForPrivateCtsAsync(dispatcher);
+
+ // Dispose the CTS to simulate race
+ cts.Dispose();
+
+ var act = () => dispatcher.Cancel();
+
+ act.Should().NotThrow();
+ }
+
+ [Test]
+ [Explicit]
+ public async Task Cancel_Race_Stress_NoUnhandledExceptions()
+ {
+ var dispatcher = new DebounceDispatcher(TimeSpan.FromMilliseconds(50));
+
+ var tasks = new Task[100];
+ for (var i = 0; i < tasks.Length; i++)
+ {
+ tasks[i] = Task.Run(async () =>
+ {
+ var debounceTask = dispatcher.DebounceAsync(() => Task.CompletedTask);
+ // ReSharper disable once MethodHasAsyncOverload
+ dispatcher.Cancel();
+ await debounceTask;
+ });
+ }
+
+ var act = async () => await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10));
+
+ await act.Should().NotThrowAsync();
+ }
+
+ [Test]
+ public async Task CancelAsync_Swallows_AggregateException_From_Callbacks()
+ {
+ var dispatcher = new DebounceDispatcher(TimeSpan.FromMilliseconds(200));
+
+ // create CTS by starting a debounce
+ _ = dispatcher.DebounceAsync(() => Task.CompletedTask);
+
+ var cts = await WaitForPrivateCtsAsync(dispatcher);
+
+ // register a callback that throws when Cancel() is called
+ cts.Token.Register(() => throw new InvalidOperationException("callback fail"));
+
+ // Cancel should swallow exceptions
+ var act = () => dispatcher.CancelAsync();
+
+ await act.Should().NotThrowAsync();
+ }
+
+ [Test]
+ public async Task CancelAsync_Swallows_ObjectDisposedException_When_CtsDisposed()
+ {
+ var dispatcher = new DebounceDispatcher(TimeSpan.FromMilliseconds(200));
+
+ _ = dispatcher.DebounceAsync(() => Task.CompletedTask);
+ var cts = await WaitForPrivateCtsAsync(dispatcher);
+
+ // Dispose the CTS to simulate race
+ cts.Dispose();
+
+ var act = () => dispatcher.CancelAsync();
+
+ await act.Should().NotThrowAsync();
+ }
+
+ [Test]
+ [Explicit]
+ public async Task CancelAsync_Race_Stress_NoUnhandledExceptions()
+ {
+ var dispatcher = new DebounceDispatcher(TimeSpan.FromMilliseconds(50));
+
+ var tasks = new Task[100];
+ for (var i = 0; i < tasks.Length; i++)
+ {
+ tasks[i] = Task.Run(async () =>
+ {
+ var debounceTask = dispatcher.DebounceAsync(() => Task.CompletedTask);
+ await dispatcher.CancelAsync();
+ await debounceTask;
+ });
+ }
+
+ var act = async () => await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10));
+
+ await act.Should().NotThrowAsync();
+ }
+
+ [Test]
+ public async Task Dispose_Swallows_AggregateException_From_Callbacks()
+ {
+ var dispatcher = new DebounceDispatcher(TimeSpan.FromMilliseconds(200));
+
+ // create CTS by starting a debounce
+ _ = dispatcher.DebounceAsync(() => Task.CompletedTask);
+
+ var cts = await WaitForPrivateCtsAsync(dispatcher);
+
+ // register a callback that throws when Cancel() is called
+ cts.Token.Register(() => throw new InvalidOperationException("callback fail"));
+
+ // Cancel should swallow exceptions
+ var act = () => dispatcher.Dispose();
+
+ act.Should().NotThrow();
+ }
+
+ [Test]
+ public async Task Dispose_Swallows_ObjectDisposedException_When_CtsDisposed()
+ {
+ var dispatcher = new DebounceDispatcher(TimeSpan.FromMilliseconds(200));
+
+ _ = dispatcher.DebounceAsync(() => Task.CompletedTask);
+ var cts = await WaitForPrivateCtsAsync(dispatcher);
+
+ // Dispose the CTS to simulate race
+ cts.Dispose();
+
+ var act = () => dispatcher.Dispose();
+
+ act.Should().NotThrow();
+ }
+
+ [Test]
+ public async Task Dispose_ConcurrentWithDebounceCalls_DoesNotHangOrThrow()
+ {
+ var dispatcher = new DebounceDispatcher(TimeSpan.FromMilliseconds(50));
+
+ var workers = Enumerable.Range(0, 100)
+ .Select(_ => Task.Run(async () =>
+ {
+ await dispatcher.DebounceAsync(() => Task.CompletedTask);
+ }))
+ .ToArray();
+
+ var disposer = Task.Run(() => dispatcher.Dispose());
+
+ var act = async () => await Task.WhenAll(workers.Append(disposer)).WaitAsync(TimeSpan.FromSeconds(5));
+
+ await act.Should().NotThrowAsync();
+ }
+
+ private static CancellationTokenSource? GetPrivateCts(object dispatcher)
+ {
+ var field = dispatcher.GetType().GetField("_cancellationTokenSource", BindingFlags.NonPublic | BindingFlags.Instance);
+ return (CancellationTokenSource?)field?.GetValue(dispatcher);
+ }
+
+ private static async Task WaitForPrivateCtsAsync(object dispatcher)
+ {
+ var timeoutTask = Task.Delay(TimeSpan.FromSeconds(1));
+ while (!timeoutTask.IsCompleted)
+ {
+ var cts = GetPrivateCts(dispatcher);
+ if (cts is not null)
+ {
+ return cts;
+ }
+
+ await Task.Yield();
+ }
+
+ Assert.Fail("Timed out waiting for DebounceDispatcher to create its cancellation token source.");
+ return null!;
+ }
+
+ private static async Task WaitUntilAsync(Func condition, TimeSpan timeout)
+ {
+ var timeoutTask = Task.Delay(timeout);
+ while (!timeoutTask.IsCompleted)
+ {
+ if (condition())
+ {
+ return true;
+ }
+
+ await Task.Yield();
+ }
+
+ return condition();
+ }
+}