From dbba6f1ed51513442a765b9303aeba9ac4341da5 Mon Sep 17 00:00:00 2001 From: Drastamat Sargsyan Date: Sat, 18 Apr 2026 18:18:21 +0400 Subject: [PATCH 1/4] Initial --- .../MudDebouncedInputExtended.cs | 185 ++++--- .../MudTextFieldExtended.razor | 1 - .../Utilities/DebounceDispatcher.cs | 503 ++++++++++++++++++ .../Pages/Index.razor | 2 + .../DebounceTextFieldTest.razor | 21 + 5 files changed, 633 insertions(+), 79 deletions(-) create mode 100644 src/CodeBeam.MudBlazor.Extensions/Utilities/DebounceDispatcher.cs create mode 100644 tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/TextFieldExtended/DebounceTextFieldTest.razor 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 From 1a5b717934adafef2449edfc2a70e9686e29d7ef Mon Sep 17 00:00:00 2001 From: Drastamat Sargsyan Date: Mon, 20 Apr 2026 11:06:49 +0400 Subject: [PATCH 2/4] Add some debounce tests (from MudBlazor) --- .../wwwroot/CodeBeam.MudBlazor.Extensions.xml | 193 ++++++++++++++++-- .../CodeBeam.MudBlazor.Extensions.Docs.csproj | 2 +- .../Base/MudBaseInputExtended.cs | 12 +- .../CodeBeam.MudBlazor.Extensions.csproj | 2 +- ...TextFieldAsyncInitializationSyncTest.razor | 21 ++ .../Components/DebouncedInputExtendedTests.cs | 106 ++++++++++ 6 files changed, 314 insertions(+), 22 deletions(-) create mode 100644 tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/TextFieldExtended/DebouncedTextFieldAsyncInitializationSyncTest.razor create mode 100644 tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/DebouncedInputExtendedTests.cs 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/CodeBeam.MudBlazor.Extensions.csproj b/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj index 8ac5c2e4..1a76343f 100644 --- a/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj +++ b/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj @@ -41,7 +41,7 @@ - + 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/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)); + } +} From 0a19cc5aa1ad32f46d08f42c95b87fbbf4f53e37 Mon Sep 17 00:00:00 2001 From: Drastamat Sargsyan Date: Mon, 20 Apr 2026 11:13:41 +0400 Subject: [PATCH 3/4] Add DebounceDispatcher tests (From MudBlazor) --- ...Beam.MudBlazor.Extensions.UnitTests.csproj | 1 + .../Utilities/DebounceDispatcherTests.cs | 992 ++++++++++++++++++ 2 files changed, 993 insertions(+) create mode 100644 tests/CodeBeam.MudBlazor.Extensions.UnitTests/Utilities/DebounceDispatcherTests.cs 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/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(); + } +} From 477980643db2beb807129e4980b243576dd2540f Mon Sep 17 00:00:00 2001 From: Drastamat Sargsyan Date: Mon, 20 Apr 2026 15:40:31 +0400 Subject: [PATCH 4/4] Revert the Mud's version --- .../CodeBeam.MudBlazor.Extensions.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj b/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj index 1a76343f..8ac5c2e4 100644 --- a/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj +++ b/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj @@ -41,7 +41,7 @@ - +