From 09febad0970ec72ea462f4e18548add028126201 Mon Sep 17 00:00:00 2001
From: Denis Voituron
Date: Wed, 18 Sep 2024 13:01:05 +0200
Subject: [PATCH 1/3] Replace Debouncer with the new DebounceTask v5 (#2678)
---
...crosoft.FluentUI.AspNetCore.Components.xml | 126 +++++++++
src/Core/Components/Base/FluentInputBase.cs | 2 +-
.../Base/FluentInputBaseHandlers.cs | 4 +-
.../List/FluentAutocomplete.razor.cs | 6 +-
.../TreeView/FluentTreeView.razor.cs | 6 +-
src/Core/Utilities/Debounce.cs | 13 +
src/Core/Utilities/Debouncer.cs | 64 -----
.../InternalDebounce/DebounceAction.cs | 75 ++++++
.../InternalDebounce/DebounceTask.cs | 99 +++++++
.../DispatcherTimerExtensions.cs | 108 ++++++++
tests/Core/Utilities/DebounceActionTests.cs | 241 +++++++++++++++++
tests/Core/Utilities/DebounceTaskTests.cs | 244 ++++++++++++++++++
tests/Core/Utilities/DebouncerTests.cs | 87 -------
13 files changed, 916 insertions(+), 159 deletions(-)
create mode 100644 src/Core/Utilities/Debounce.cs
delete mode 100644 src/Core/Utilities/Debouncer.cs
create mode 100644 src/Core/Utilities/InternalDebounce/DebounceAction.cs
create mode 100644 src/Core/Utilities/InternalDebounce/DebounceTask.cs
create mode 100644 src/Core/Utilities/InternalDebounce/DispatcherTimerExtensions.cs
create mode 100644 tests/Core/Utilities/DebounceActionTests.cs
create mode 100644 tests/Core/Utilities/DebounceTaskTests.cs
delete mode 100644 tests/Core/Utilities/DebouncerTests.cs
diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml
index 3b3afcc4e0..e58459dabc 100644
--- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml
+++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml
@@ -15319,6 +15319,12 @@
+
+
+ The DebounceTask dispatcher delays the invocation of an action until a predetermined interval has elapsed since the last call.
+ This ensures that the action is only invoked once after the calls have stopped for the specified duration.
+
+
Initializes a new instance of the class.
@@ -15374,6 +15380,126 @@
StyleBuilder
+
+
+ The DebounceTask dispatcher delays the invocation of an action until a predetermined interval has elapsed since the last call.
+ This ensures that the action is only invoked once after the calls have stopped for the specified duration.
+
+
+
+
+ Gets a value indicating whether the DebounceTask dispatcher is busy.
+
+
+
+
+ Gets the current task.
+
+
+
+
+ Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+
+
+
+
+
+
+
+ Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+
+
+
+
+
+
+
+ Releases all resources used by the DebounceTask dispatcher.
+
+
+
+
+ The DebounceTask dispatcher delays the invocation of an action until a predetermined interval has elapsed since the last call.
+ This ensures that the action is only invoked once after the calls have stopped for the specified duration.
+
+
+
+
+ Gets a value indicating whether the DebounceTask dispatcher is busy.
+
+
+
+
+ Gets the current task.
+
+
+
+
+ Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+
+
+
+
+
+
+
+ Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+
+
+
+
+
+
+
+ Releases all resources used by the DebounceTask dispatcher.
+
+
+
+
+ Extension methods for .
+
+
+ Inspired from Microsoft.Toolkit.Uwp.UI.DispatcherQueueTimerExtensions
+
+
+
+
+ Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+
+
+
+
+
+
+
+
+ Timer elapsed event handler.
+
+
+
+
+
+
+ Timer debounce item.
+
+
+
+
+ Gets the task completion source.
+
+
+
+
+ Gets or sets the action to execute.
+
+
+
+
+ Updates the action to execute.
+
+
+
+
Flags for a member that is JSON (de)serialized.
diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs
index bf872b7198..9622a11e5f 100644
--- a/src/Core/Components/Base/FluentInputBase.cs
+++ b/src/Core/Components/Base/FluentInputBase.cs
@@ -467,7 +467,7 @@ void IDisposable.Dispose()
EditContext.OnValidationStateChanged -= _validationStateChangedHandler;
}
- _debouncer.Dispose();
+ _debounce.Dispose();
Dispose(disposing: true);
}
diff --git a/src/Core/Components/Base/FluentInputBaseHandlers.cs b/src/Core/Components/Base/FluentInputBaseHandlers.cs
index b051cb303c..71a26eb396 100644
--- a/src/Core/Components/Base/FluentInputBaseHandlers.cs
+++ b/src/Core/Components/Base/FluentInputBaseHandlers.cs
@@ -6,7 +6,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components;
public partial class FluentInputBase
{
- private readonly Debouncer _debouncer = new();
+ private readonly Debounce _debounce = new();
///
/// Change the content of this input field when the user write text (based on 'OnInput' HTML event).
@@ -64,7 +64,7 @@ protected virtual async Task InputHandlerAsync(ChangeEventArgs e) // TODO: To up
}
if (ImmediateDelay > 0)
{
- await _debouncer.DebounceAsync(ImmediateDelay, async () => await ChangeHandlerAsync(e));
+ await _debounce.RunAsync(ImmediateDelay, async () => await ChangeHandlerAsync(e));
}
else
{
diff --git a/src/Core/Components/List/FluentAutocomplete.razor.cs b/src/Core/Components/List/FluentAutocomplete.razor.cs
index e5155e0788..6f76f7053f 100644
--- a/src/Core/Components/List/FluentAutocomplete.razor.cs
+++ b/src/Core/Components/List/FluentAutocomplete.razor.cs
@@ -18,7 +18,7 @@ public partial class FluentAutocomplete : ListComponentBase wh
public new FluentTextField? Element { get; set; } = default!;
private Virtualize? VirtualizationContainer { get; set; }
- private readonly Debouncer _debouncer = new();
+ private readonly Debounce _debounce = new();
///
/// Initializes a new instance of the class.
@@ -295,13 +295,15 @@ protected override async Task InputHandlerAsync(ChangeEventArgs e)
if (ImmediateDelay > 0)
{
- await _debouncer.DebounceAsync(ImmediateDelay, () => InvokeAsync(() => OnOptionsSearch.InvokeAsync(args)));
+ await _debounce.RunAsync(ImmediateDelay, () => InvokeAsync(() => OnOptionsSearch.InvokeAsync(args)));
}
else
{
await OnOptionsSearch.InvokeAsync(args);
}
+ Console.WriteLine($"args.Items: {args.Items?.Count()}");
+
Items = args.Items?.Take(MaximumOptionsSearch);
SelectableItem = Items != null
diff --git a/src/Core/Components/TreeView/FluentTreeView.razor.cs b/src/Core/Components/TreeView/FluentTreeView.razor.cs
index 16abfc8d61..e6004b5f74 100644
--- a/src/Core/Components/TreeView/FluentTreeView.razor.cs
+++ b/src/Core/Components/TreeView/FluentTreeView.razor.cs
@@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components;
public partial class FluentTreeView : FluentComponentBase, IDisposable
{
private readonly Dictionary _allItems = [];
- private readonly Debouncer _currentSelectedChangedDebouncer = new();
+ private readonly Debounce _currentSelectedChangedDebounce = new();
private bool _disposed;
public static string LoadingMessage = "Loading...";
@@ -154,7 +154,7 @@ internal async Task HandleCurrentSelectedChangeAsync(TreeChangeEventArgs args)
}
var previouslySelected = CurrentSelected;
- await _currentSelectedChangedDebouncer.DebounceAsync(50, () => InvokeAsync(async () =>
+ await _currentSelectedChangedDebounce.RunAsync(50, () => InvokeAsync(async () =>
{
CurrentSelected = treeItem?.Selected == true ? treeItem : null;
if (CurrentSelected != previouslySelected && CurrentSelectedChanged.HasDelegate)
@@ -190,7 +190,7 @@ protected virtual void Dispose(bool disposing)
if (disposing)
{
- _currentSelectedChangedDebouncer?.Dispose();
+ _currentSelectedChangedDebounce?.Dispose();
_allItems.Clear();
}
diff --git a/src/Core/Utilities/Debounce.cs b/src/Core/Utilities/Debounce.cs
new file mode 100644
index 0000000000..6cac0f44b1
--- /dev/null
+++ b/src/Core/Utilities/Debounce.cs
@@ -0,0 +1,13 @@
+// ------------------------------------------------------------------------
+// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------------------
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Utilities;
+
+///
+/// The DebounceTask dispatcher delays the invocation of an action until a predetermined interval has elapsed since the last call.
+/// This ensures that the action is only invoked once after the calls have stopped for the specified duration.
+///
+public sealed class Debounce : InternalDebounce.DebounceTask
+{
+}
diff --git a/src/Core/Utilities/Debouncer.cs b/src/Core/Utilities/Debouncer.cs
deleted file mode 100644
index 27179a2803..0000000000
--- a/src/Core/Utilities/Debouncer.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using Timer = System.Timers.Timer;
-
-namespace Microsoft.FluentUI.AspNetCore.Components.Utilities;
-
-internal sealed class Debouncer : IDisposable
-{
- private readonly object _syncRoot = new();
- private bool _disposed;
- private Timer? _timer;
- private TaskCompletionSource? _taskCompletionSource;
-
- public bool Busy => _timer is not null && !_disposed;
-
- public Task DebounceAsync(double milliseconds, Func action)
- {
- ArgumentNullException.ThrowIfNull(action);
-
- lock (_syncRoot)
- {
- _taskCompletionSource?.TrySetResult(false);
- _taskCompletionSource = null;
-
- _timer?.Dispose();
- _timer = null;
-
- Timer newTimer = _timer = new Timer(milliseconds);
- TaskCompletionSource newTaskCompletionSource = _taskCompletionSource = new TaskCompletionSource();
-
- newTimer.Elapsed += async (_, _) =>
- {
- newTimer.Stop();
- try
- {
- if (!_disposed)
- {
- await action();
- }
- }
- finally
- {
- lock (_syncRoot)
- {
- if (_timer == newTimer)
- {
- _timer.Dispose();
- _timer = null;
- newTaskCompletionSource?.SetResult(!_disposed);
- }
- }
- }
- };
-
- newTimer.Start();
- return newTaskCompletionSource.Task;
- }
- }
-
- public void Dispose()
- {
- _disposed = true;
- _timer?.Dispose();
- }
-}
-
diff --git a/src/Core/Utilities/InternalDebounce/DebounceAction.cs b/src/Core/Utilities/InternalDebounce/DebounceAction.cs
new file mode 100644
index 0000000000..c91a8dc320
--- /dev/null
+++ b/src/Core/Utilities/InternalDebounce/DebounceAction.cs
@@ -0,0 +1,75 @@
+// ------------------------------------------------------------------------
+// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------------------
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce;
+
+///
+/// The DebounceTask dispatcher delays the invocation of an action until a predetermined interval has elapsed since the last call.
+/// This ensures that the action is only invoked once after the calls have stopped for the specified duration.
+///
+[Obsolete("Use Debounce, which inherits from DebounceTask.")]
+internal class DebounceAction : IDisposable
+{
+ private bool _disposed;
+ private readonly System.Timers.Timer _timer = new();
+ private TaskCompletionSource? _taskCompletionSource;
+
+ ///
+ /// Gets a value indicating whether the DebounceTask dispatcher is busy.
+ ///
+ public bool Busy => _taskCompletionSource?.Task.Status == TaskStatus.Running && !_disposed;
+
+ ///
+ /// Gets the current task.
+ ///
+ public Task CurrentTask => _taskCompletionSource?.Task ?? Task.CompletedTask;
+
+ ///
+ /// Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+ ///
+ ///
+ ///
+ ///
+ public void Run(int milliseconds, Func action)
+ {
+ // Check arguments
+ if (milliseconds <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(milliseconds), milliseconds, "The milliseconds must be greater than to zero.");
+ }
+
+ ArgumentNullException.ThrowIfNull(action);
+
+ // DebounceTask
+ if (!_disposed)
+ {
+ _taskCompletionSource = _timer.Debounce(action, milliseconds);
+ }
+ }
+
+ ///
+ /// Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+ ///
+ ///
+ ///
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Required to return the current Task.")]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "MA0042:Do not use blocking calls in an async method", Justification = "Special case using CurrentTask")]
+ public Task RunAsync(int milliseconds, Func action)
+ {
+ Run(milliseconds, action);
+ return CurrentTask;
+ }
+
+ ///
+ /// Releases all resources used by the DebounceTask dispatcher.
+ ///
+ public void Dispose()
+ {
+ _taskCompletionSource = null;
+ _timer.Dispose();
+ _disposed = true;
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/Core/Utilities/InternalDebounce/DebounceTask.cs b/src/Core/Utilities/InternalDebounce/DebounceTask.cs
new file mode 100644
index 0000000000..1aecb0c4f8
--- /dev/null
+++ b/src/Core/Utilities/InternalDebounce/DebounceTask.cs
@@ -0,0 +1,99 @@
+// ------------------------------------------------------------------------
+// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------------------
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce;
+
+///
+/// The DebounceTask dispatcher delays the invocation of an action until a predetermined interval has elapsed since the last call.
+/// This ensures that the action is only invoked once after the calls have stopped for the specified duration.
+///
+public class DebounceTask : IDisposable
+{
+#if NET9_0_OR_GREATER
+ private readonly System.Threading.Lock _syncRoot = new();
+#else
+ private readonly object _syncRoot = new();
+#endif
+
+ private bool _disposed;
+ private Task? _task;
+ private CancellationTokenSource? _cts;
+
+ ///
+ /// Gets a value indicating whether the DebounceTask dispatcher is busy.
+ ///
+ public bool Busy => _task?.Status == TaskStatus.Running && !_disposed;
+
+ ///
+ /// Gets the current task.
+ ///
+ public Task CurrentTask => _task ?? Task.CompletedTask;
+
+ ///
+ /// Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+ ///
+ ///
+ ///
+ ///
+ public void Run(int milliseconds, Func action)
+ {
+ // Check arguments
+ if (milliseconds <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(milliseconds), milliseconds, "The milliseconds must be greater than to zero.");
+ }
+
+ ArgumentNullException.ThrowIfNull(action);
+
+ // Cancel the previous task if it's still running
+ _cts?.Cancel();
+
+ // Create a new cancellation token source
+ _cts = new CancellationTokenSource();
+
+ try
+ {
+ // Wait for the specified time
+ _task = Task.Delay(TimeSpan.FromMilliseconds(milliseconds), _cts.Token)
+ .ContinueWith(t =>
+ {
+ if (!_disposed && !_cts.IsCancellationRequested)
+ {
+ lock (_syncRoot)
+ {
+ _ = action.Invoke();
+ }
+ }
+ }, _cts.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current);
+ }
+ catch (TaskCanceledException)
+ {
+ // Task was canceled
+ }
+ }
+
+ ///
+ /// Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+ ///
+ ///
+ ///
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Required to return the current Task.")]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "MA0042:Do not use blocking calls in an async method", Justification = "Special case using CurrentTask")]
+ public Task RunAsync(int milliseconds, Func action)
+ {
+ Run(milliseconds, action);
+ return CurrentTask;
+ }
+
+ ///
+ /// Releases all resources used by the DebounceTask dispatcher.
+ ///
+ public void Dispose()
+ {
+ _disposed = true;
+ _cts?.Cancel();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/Core/Utilities/InternalDebounce/DispatcherTimerExtensions.cs b/src/Core/Utilities/InternalDebounce/DispatcherTimerExtensions.cs
new file mode 100644
index 0000000000..be1799fcd5
--- /dev/null
+++ b/src/Core/Utilities/InternalDebounce/DispatcherTimerExtensions.cs
@@ -0,0 +1,108 @@
+// ------------------------------------------------------------------------
+// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------------------
+
+using System.Collections.Concurrent;
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce;
+
+///
+/// Extension methods for .
+///
+///
+/// Inspired from Microsoft.Toolkit.Uwp.UI.DispatcherQueueTimerExtensions
+///
+internal static class DispatcherTimerExtensions
+{
+ private static readonly ConcurrentDictionary _debounceInstances = new();
+
+ ///
+ /// Delays the invocation of an action until a predetermined interval has elapsed since the last call.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static TaskCompletionSource Debounce(this System.Timers.Timer timer, Func action, double interval)
+ {
+ // Check and stop any existing timer
+ timer.Stop();
+
+ // Reset timer parameters
+ timer.Elapsed -= Timer_Elapsed;
+ timer.Interval = interval;
+
+ // If we're not in immediate mode, then we'll execute when the current timer expires.
+ timer.Elapsed += Timer_Elapsed;
+
+ // Store/Update function
+ TimerDebounceItem updateValueFactory(System.Timers.Timer k, TimerDebounceItem v)
+ {
+ v.Status.SetCanceled();
+ v.Status = new TaskCompletionSource();
+ return v.UpdateAction(action);
+ }
+
+ var item = _debounceInstances.AddOrUpdate(
+ key: timer,
+ addValue: new TimerDebounceItem()
+ {
+ Status = new TaskCompletionSource(),
+ Action = action,
+ },
+ updateValueFactory: updateValueFactory);
+
+ // Start the timer to keep track of the last call here.
+ timer.Start();
+
+ return item.Status;
+ }
+
+ ///
+ /// Timer elapsed event handler.
+ ///
+ ///
+ ///
+ private static void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
+ {
+ // This event is only registered/run if we weren't in immediate mode above
+ if (sender is System.Timers.Timer timer)
+ {
+ timer.Elapsed -= Timer_Elapsed;
+ timer.Stop();
+
+ if (_debounceInstances.TryRemove(timer, out var item))
+ {
+ _ = (item?.Action.Invoke());
+ item?.Status.SetResult();
+ }
+ }
+ }
+
+ ///
+ /// Timer debounce item.
+ ///
+ private class TimerDebounceItem
+ {
+ ///
+ /// Gets the task completion source.
+ ///
+ public required TaskCompletionSource Status { get; set; }
+
+ ///
+ /// Gets or sets the action to execute.
+ ///
+ public required Func Action { get; set; }
+
+ ///
+ /// Updates the action to execute.
+ ///
+ ///
+ ///
+ public TimerDebounceItem UpdateAction(Func action)
+ {
+ Action = action;
+ return this;
+ }
+ }
+}
diff --git a/tests/Core/Utilities/DebounceActionTests.cs b/tests/Core/Utilities/DebounceActionTests.cs
new file mode 100644
index 0000000000..394ab63125
--- /dev/null
+++ b/tests/Core/Utilities/DebounceActionTests.cs
@@ -0,0 +1,241 @@
+// ------------------------------------------------------------------------
+// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------------------
+
+using System.Diagnostics;
+using Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Utilities;
+
+#pragma warning disable CS0618 // Type or member is obsolete
+
+[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "Tests purpose")]
+public class DebounceActionTests
+{
+ private readonly ITestOutputHelper Output;
+
+ public DebounceActionTests(ITestOutputHelper output)
+ {
+ Output = output;
+ }
+
+ [Fact]
+ public async Task Debounce_Default()
+ {
+ // Arrange
+ var debounce = new DebounceAction();
+ var actionCalled = false;
+ var watcher = Stopwatch.StartNew();
+
+ // Act
+ debounce.Run(50, async () =>
+ {
+ actionCalled = true;
+ await Task.CompletedTask;
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+
+ // Assert
+ Assert.True(watcher.ElapsedMilliseconds >= 50);
+ Assert.True(actionCalled);
+ }
+
+ [Fact]
+ public async Task Debounce_MultipleCalls()
+ {
+ // Arrange
+ var debounce = new DebounceAction();
+ var actionCalledCount = 0;
+ var actionCalled = string.Empty;
+
+ // Act
+ debounce.Run(50, async () =>
+ {
+ actionCalled = "Step1";
+ actionCalledCount++;
+ await Task.CompletedTask;
+ });
+
+ debounce.Run(40, async () =>
+ {
+ actionCalled = "Step2";
+ actionCalledCount++;
+ await Task.CompletedTask;
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+
+ // Assert
+ Assert.Equal("Step2", actionCalled);
+ Assert.Equal(1, actionCalledCount);
+ }
+
+ [Fact]
+ public async Task Debounce_MultipleCalls_Async()
+ {
+ // Arrange
+ var debounce = new DebounceAction();
+ var actionCalledCount = 0;
+ var actionCalled = string.Empty;
+ var actionNextCount = 0;
+ var actionNextCalled = string.Empty;
+
+ // Act: simulate two async calls
+ _ = Task.Run(async () =>
+ {
+ await debounce.RunAsync(50, async () =>
+ {
+ actionCalled = "Step1";
+ actionCalledCount++;
+ await Task.CompletedTask;
+ });
+
+ actionNextCalled = "Next1";
+ actionNextCount++;
+ });
+
+ await Task.Delay(5); // To ensure the second call is made after the first one
+
+ _ = Task.Run(async () =>
+ {
+ await debounce.RunAsync(40, async () =>
+ {
+ actionCalled = "Step2";
+ actionCalledCount++;
+ await Task.CompletedTask;
+ });
+
+ actionNextCalled = "Next2";
+ actionNextCount++;
+ });
+
+ await Task.Delay(100); // Wait for the debounce to complete
+
+ // Assert
+ Assert.Equal("Step2", actionCalled);
+ Assert.Equal(1, actionCalledCount);
+
+ Assert.Equal("Next2", actionNextCalled);
+ Assert.Equal(1, actionNextCount);
+ }
+
+ [Fact]
+ public async Task Debounce_Disposed()
+ {
+ // Arrange
+ var debounce = new DebounceAction();
+ var actionCalled = false;
+
+ // Act
+ debounce.Dispose();
+
+ debounce.Run(50, async () =>
+ {
+ actionCalled = true;
+ await Task.CompletedTask;
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+
+ // Assert
+ Assert.False(actionCalled);
+ }
+
+ [Fact]
+ public async Task Debounce_Busy()
+ {
+ // Arrange
+ var debounce = new DebounceAction();
+
+ // Act
+ debounce.Run(50, async () =>
+ {
+ await Task.CompletedTask;
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+
+ // Assert
+ Assert.False(debounce.Busy);
+ }
+
+ [Fact]
+ public async Task Debounce_Exception()
+ {
+ // Arrange
+ var debounce = new DebounceAction();
+
+ // Act
+ debounce.Run(50, async () =>
+ {
+ await Task.CompletedTask;
+ throw new InvalidProgramException("Error"); // Simulate an exception
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+
+ // Assert
+ Assert.False(debounce.Busy);
+ }
+
+ [Fact]
+ public void Debounce_DelayMustBePositive()
+ {
+ // Arrange
+ var debounce = new DebounceAction();
+
+ // Act
+ Assert.Throws(() =>
+ {
+ debounce.Run(-10, () => Task.CompletedTask);
+ });
+ }
+
+ [Fact]
+ public async Task Debounce_FirstRunAlreadyStarted()
+ {
+ // Arrange
+ var debounce = new DebounceAction();
+ var actionCalledCount = 0;
+
+ // Act
+ debounce.Run(10, async () =>
+ {
+ Output.WriteLine("Step1 - Started");
+
+ await Task.Delay(100);
+ actionCalledCount++;
+
+ Output.WriteLine("Step1 - Completed");
+ });
+
+ await Task.Delay(20); // Wait for Step1 to start.
+
+ debounce.Run(10, async () =>
+ {
+ Output.WriteLine("Step2 - Started");
+
+ await Task.CompletedTask;
+ actionCalledCount++;
+
+ Output.WriteLine("Step2 - Completed");
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+ await Task.Delay(200);
+
+ // Assert
+ Assert.Equal(2, actionCalledCount);
+ }
+}
+
+#pragma warning restore CS0618 // Type or member is obsolete
diff --git a/tests/Core/Utilities/DebounceTaskTests.cs b/tests/Core/Utilities/DebounceTaskTests.cs
new file mode 100644
index 0000000000..51e1c08683
--- /dev/null
+++ b/tests/Core/Utilities/DebounceTaskTests.cs
@@ -0,0 +1,244 @@
+// ------------------------------------------------------------------------
+// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------------------
+
+using System.Diagnostics;
+using Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Utilities;
+
+[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "Tests purpose")]
+public class DebounceTaskTests
+{
+ private readonly ITestOutputHelper Output;
+
+ public DebounceTaskTests(ITestOutputHelper output)
+ {
+ Output = output;
+ }
+
+ [Fact]
+ public async Task Debounce_Default()
+ {
+ // Arrange
+ var debounce = new DebounceTask();
+ var actionCalled = false;
+ var watcher = Stopwatch.StartNew();
+
+ // Act
+ debounce.Run(50, async () =>
+ {
+ actionCalled = true;
+ await Task.CompletedTask;
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+
+ // Assert
+ Assert.True(watcher.ElapsedMilliseconds >= 50);
+ Assert.True(actionCalled);
+ }
+
+ [Fact]
+ public async Task Debounce_MultipleCalls()
+ {
+ // Arrange
+ var debounce = new DebounceTask();
+ var actionCalledCount = 0;
+ var actionCalled = string.Empty;
+
+ // Act
+ debounce.Run(50, async () =>
+ {
+ actionCalled = "Step1";
+ actionCalledCount++;
+ await Task.CompletedTask;
+ });
+
+ debounce.Run(40, async () =>
+ {
+ actionCalled = "Step2";
+ actionCalledCount++;
+ await Task.CompletedTask;
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+
+ // Assert
+ Assert.Equal("Step2", actionCalled);
+ Assert.Equal(1, actionCalledCount);
+ }
+
+ [Fact]
+ public async Task Debounce_MultipleCalls_Async()
+ {
+ // Arrange
+ var debounce = new DebounceTask();
+ var actionCalledCount = 0;
+ var actionCalled = string.Empty;
+ var actionNextCount = 0;
+ var actionNextCalled = string.Empty;
+
+ // Act: simulate two async calls
+ var t1 = Task.Run(async () =>
+ {
+ try
+ {
+ await debounce.RunAsync(50, async () =>
+ {
+ actionCalled = "Step1";
+ actionCalledCount++;
+ await Task.CompletedTask;
+ });
+
+ actionNextCalled = "Next1";
+ actionNextCount++;
+ }
+ catch (OperationCanceledException)
+ {
+ // Task cancelled
+ }
+ });
+
+ await Task.Delay(15); // Wait for the first task to start
+
+ var t2 = Task.Run(async () =>
+ {
+ await debounce.RunAsync(40, async () =>
+ {
+ actionCalled = "Step2";
+ actionCalledCount++;
+ await Task.CompletedTask;
+ });
+
+ actionNextCalled = "Next2";
+ actionNextCount++;
+ });
+
+ await Task.WhenAll(t1, t2);
+
+ // Assert
+ Assert.Equal("Step2", actionCalled);
+ Assert.Equal(1, actionCalledCount);
+
+ Assert.Equal("Next2", actionNextCalled);
+ Assert.Equal(1, actionNextCount);
+ }
+
+ [Fact]
+ public async Task Debounce_Disposed()
+ {
+ // Arrange
+ var debounce = new DebounceTask();
+ var actionCalled = false;
+
+ // Act
+ debounce.Dispose();
+
+ debounce.Run(50, async () =>
+ {
+ actionCalled = true;
+ await Task.CompletedTask;
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+
+ // Assert
+ Assert.False(actionCalled);
+ }
+
+ [Fact]
+ public async Task Debounce_Busy()
+ {
+ // Arrange
+ var debounce = new DebounceTask();
+
+ // Act
+ debounce.Run(50, async () =>
+ {
+ await Task.CompletedTask;
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+
+ // Assert
+ Assert.False(debounce.Busy);
+ }
+
+ [Fact]
+ public async Task Debounce_Exception()
+ {
+ // Arrange
+ var debounce = new DebounceTask();
+
+ // Act
+ debounce.Run(50, async () =>
+ {
+ await Task.CompletedTask;
+ throw new InvalidProgramException("Error"); // Simulate an exception
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+
+ // Assert
+ Assert.False(debounce.Busy);
+ }
+
+ [Fact]
+ public void Debounce_DelayMustBePositive()
+ {
+ // Arrange
+ var debounce = new DebounceTask();
+
+ // Act
+ Assert.Throws(() =>
+ {
+ debounce.Run(-10, () => Task.CompletedTask);
+ });
+ }
+
+ [Fact]
+ public async Task Debounce_FirstRunAlreadyStarted()
+ {
+ // Arrange
+ var debounce = new DebounceTask();
+ var actionCalledCount = 0;
+
+ // Act
+ debounce.Run(10, async () =>
+ {
+ Output.WriteLine("Step1 - Started");
+
+ await Task.Delay(100);
+ actionCalledCount++;
+
+ Output.WriteLine("Step1 - Completed");
+ });
+
+ await Task.Delay(20); // Wait for Step1 to start.
+
+ debounce.Run(10, async () =>
+ {
+ Output.WriteLine("Step2 - Started");
+
+ await Task.CompletedTask;
+ actionCalledCount++;
+
+ Output.WriteLine("Step2 - Completed");
+ });
+
+ // Wait for the debounce to complete
+ await debounce.CurrentTask;
+ await Task.Delay(200);
+
+ // Assert
+ Assert.Equal(2, actionCalledCount);
+ }
+}
diff --git a/tests/Core/Utilities/DebouncerTests.cs b/tests/Core/Utilities/DebouncerTests.cs
deleted file mode 100644
index 1d82ccf59f..0000000000
--- a/tests/Core/Utilities/DebouncerTests.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using Microsoft.FluentUI.AspNetCore.Components.Utilities;
-using Xunit;
-
-namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Utilities;
-
-public class DebouncerTests
-{
- [Fact]
- public async Task DebounceAsync_ShouldCallAction_WhenTimerElapsed()
- {
- // Arrange
- var debouncer = new Debouncer();
- var actionCalled = false;
-
- // Act
- await debouncer.DebounceAsync(100, async () =>
- {
- actionCalled = true;
- await Task.Delay(50);
- });
-
- // Assert
- Assert.True(actionCalled);
- }
-
- //[Fact]
- //public async Task DebounceAsync_ShouldNotCallAction_WhenCalledMultipleTimesWithinTimerInterval()
- //{
- // // Arrange
- // var debouncer = new Debouncer();
- // var actionCalledCount = 0;
-
- // // Act
- // await debouncer.DebounceAsync(100, async () =>
- // {
- // actionCalledCount++;
- // await Task.Delay(50);
- // });
- // await debouncer.DebounceAsync(100, async () =>
- // {
- // actionCalledCount++;
- // await Task.Delay(50);
- // });
-
- // // Assert
- // Assert.Equal(1, actionCalledCount);
- //}
-
- [Fact]
- public async Task DebounceAsync_ShouldNotCallAction_WhenDisposed()
- {
- // Arrange
- var debouncer = new Debouncer();
- var actionCalled = false;
-
- // Act
- debouncer.Dispose();
- await debouncer.DebounceAsync(100, async () =>
- {
- actionCalled = true;
- await Task.Delay(50);
- });
-
- // Assert
- Assert.False(actionCalled);
- }
-
- [Fact]
- public async Task DebounceAsync_Busy()
- {
- // Arrange
- var debouncer = new Debouncer();
- var busy = false;
-
- // Act
- debouncer.Dispose();
- await debouncer.DebounceAsync(100, async () =>
- {
- busy = debouncer.Busy;
- Assert.True(busy);
- await Task.Delay(50);
- });
-
- // Assert
- Assert.False(busy);
- }
-}
From 5294a4ddb59c282b94df00a26eac1a2e612e98ac Mon Sep 17 00:00:00 2001
From: Gary Chan
Date: Wed, 18 Sep 2024 04:08:11 -0700
Subject: [PATCH 2/3] [Docs] Blazor Hybrid code snippet reformat (#2673)
Co-authored-by: Vincent Baaij
---
examples/Demo/Shared/Pages/BlazorHybrid.razor | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/examples/Demo/Shared/Pages/BlazorHybrid.razor b/examples/Demo/Shared/Pages/BlazorHybrid.razor
index 92793b741b..9c72eadd4b 100644
--- a/examples/Demo/Shared/Pages/BlazorHybrid.razor
+++ b/examples/Demo/Shared/Pages/BlazorHybrid.razor
@@ -18,9 +18,11 @@
included in the general library script and needs to be included with a script tag before the _framework/blazor.webview.js
script tag.
-
- <script app-name="{NAME OF YOUR APP}" src="./_content/Microsoft.FluentUI.AspNetCore.Components/js/initializersLoader.webview.js"></script>
- <script src="_framework/blazor.webview.js"></script>
+
+ @(
+@"
+"
+ )
From 35f3e0496c6f0fe2a9b746448f7b525a361ffe8e Mon Sep 17 00:00:00 2001
From: Denis Voituron
Date: Wed, 18 Sep 2024 13:33:37 +0200
Subject: [PATCH 3/3] Fix Unit Test (#2679)
---
tests/Core/Utilities/DebounceActionTests.cs | 31 +++++++++++++--------
1 file changed, 19 insertions(+), 12 deletions(-)
diff --git a/tests/Core/Utilities/DebounceActionTests.cs b/tests/Core/Utilities/DebounceActionTests.cs
index 394ab63125..ca75ec3d10 100644
--- a/tests/Core/Utilities/DebounceActionTests.cs
+++ b/tests/Core/Utilities/DebounceActionTests.cs
@@ -86,22 +86,29 @@ public async Task Debounce_MultipleCalls_Async()
var actionNextCalled = string.Empty;
// Act: simulate two async calls
- _ = Task.Run(async () =>
+ var t1 = Task.Run(async () =>
{
- await debounce.RunAsync(50, async () =>
+ try
{
- actionCalled = "Step1";
- actionCalledCount++;
- await Task.CompletedTask;
- });
-
- actionNextCalled = "Next1";
- actionNextCount++;
+ await debounce.RunAsync(50, async () =>
+ {
+ actionCalled = "Step1";
+ actionCalledCount++;
+ await Task.CompletedTask;
+ });
+
+ actionNextCalled = "Next1";
+ actionNextCount++;
+ }
+ catch (OperationCanceledException)
+ {
+ // Task cancelled
+ }
});
- await Task.Delay(5); // To ensure the second call is made after the first one
+ await Task.Delay(15); // Wait for the first task to start
- _ = Task.Run(async () =>
+ var t2 = Task.Run(async () =>
{
await debounce.RunAsync(40, async () =>
{
@@ -114,7 +121,7 @@ await debounce.RunAsync(40, async () =>
actionNextCount++;
});
- await Task.Delay(100); // Wait for the debounce to complete
+ await Task.WhenAll(t1, t2);
// Assert
Assert.Equal("Step2", actionCalled);