Skip to content

Commit 3766a65

Browse files
committed
Feat: Add EventCallbackDebouncer with unit tests
Introduced `EventCallbackDebouncer` and `EventCallbackDebouncer<T>` to debounce event callbacks. Added relevant extension methods and comprehensive unit tests to validate their functionality. Fixed `ToGuid` test to throw `FormatException` during invalid input scenarios.
1 parent 5ec1a68 commit 3766a65

File tree

6 files changed

+417
-1
lines changed

6 files changed

+417
-1
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
5+
// ReSharper disable once CheckNamespace
6+
namespace Microsoft.AspNetCore.Components;
7+
// ---------------------------------------------------------------------------------------------------------------------
8+
// Code
9+
// ---------------------------------------------------------------------------------------------------------------------
10+
public class EventCallbackDebouncer(EventCallback callback, int debounceMs = EventCallbackDebouncer.DefaultDebounceMs)
11+
: IAsyncDisposable {
12+
13+
private const int DefaultDebounceMs = 100;
14+
private readonly SemaphoreSlim _semaphore = new(1, 1);
15+
private CancellationTokenSource? _cts;
16+
private Task? _debounceTask;
17+
private bool _isDisposed;
18+
19+
public async Task InvokeDebouncedAsync() {
20+
ObjectDisposedException.ThrowIf(_isDisposed, this);
21+
22+
await _semaphore.WaitAsync();
23+
try {
24+
if (_cts is not null) await _cts.CancelAsync();
25+
_cts?.Dispose();
26+
27+
_cts = new CancellationTokenSource();
28+
CancellationTokenSource? localCts = _cts;
29+
30+
_debounceTask = Task.Run(function: async () => {
31+
try {
32+
await Task.Delay(debounceMs, localCts.Token);
33+
if (!localCts.Token.IsCancellationRequested && callback.HasDelegate) {
34+
await callback.InvokeAsync();
35+
}
36+
}
37+
catch (OperationCanceledException) {
38+
// Ignore
39+
}
40+
}, localCts.Token);
41+
}
42+
finally {
43+
_semaphore.Release();
44+
}
45+
}
46+
47+
public async ValueTask DisposeAsync() {
48+
if (_isDisposed) return;
49+
50+
_isDisposed = true;
51+
52+
await _semaphore.WaitAsync();
53+
try {
54+
_cts?.Cancel();
55+
_cts?.Dispose();
56+
if (_debounceTask is not null) {
57+
try { await _debounceTask; }
58+
catch {
59+
/* Ignore */
60+
}
61+
}
62+
}
63+
finally {
64+
_semaphore.Dispose();
65+
}
66+
67+
GC.SuppressFinalize(this);
68+
}
69+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
5+
// ReSharper disable once CheckNamespace
6+
namespace Microsoft.AspNetCore.Components;
7+
// ---------------------------------------------------------------------------------------------------------------------
8+
// Code
9+
// ---------------------------------------------------------------------------------------------------------------------
10+
public class EventCallbackDebouncer<T>(EventCallback<T> callback, int debounceMs = EventCallbackDebouncer<T>.DefaultDebounceMs)
11+
: IAsyncDisposable {
12+
13+
private const int DefaultDebounceMs = 100;
14+
private readonly SemaphoreSlim _semaphore = new(1, 1);
15+
private CancellationTokenSource? _cts;
16+
private Task? _debounceTask;
17+
private bool _isDisposed;
18+
private T? _latestValue;
19+
20+
public async Task InvokeDebouncedAsync(T? value = default) {
21+
ObjectDisposedException.ThrowIf(_isDisposed, this);
22+
23+
await _semaphore.WaitAsync();
24+
try {
25+
_latestValue = value;
26+
27+
if (_cts is not null) {
28+
await _cts.CancelAsync();
29+
_cts.Dispose();
30+
}
31+
32+
_cts = new CancellationTokenSource();
33+
CancellationToken token = _cts.Token;
34+
35+
_debounceTask = Task.Run(function: async () => {
36+
try {
37+
await Task.Delay(debounceMs, token);
38+
if (!token.IsCancellationRequested && callback.HasDelegate) {
39+
await callback.InvokeAsync(_latestValue);
40+
}
41+
}
42+
catch (OperationCanceledException) {
43+
// Ignore
44+
}
45+
}, token);
46+
}
47+
finally {
48+
_semaphore.Release();
49+
}
50+
}
51+
52+
public async ValueTask DisposeAsync() {
53+
if (_isDisposed) return;
54+
55+
_isDisposed = true;
56+
57+
await _semaphore.WaitAsync();
58+
try {
59+
if (_cts is not null) {
60+
await _cts.CancelAsync();
61+
_cts.Dispose();
62+
}
63+
64+
if (_debounceTask is not null) {
65+
try { await _debounceTask; }
66+
catch {
67+
// Ignore
68+
}
69+
}
70+
}
71+
finally {
72+
_semaphore.Dispose();
73+
}
74+
75+
GC.SuppressFinalize(this);
76+
}
77+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
5+
// ReSharper disable once CheckNamespace
6+
namespace Microsoft.AspNetCore.Components;
7+
8+
// ---------------------------------------------------------------------------------------------------------------------
9+
// Code
10+
// ---------------------------------------------------------------------------------------------------------------------
11+
public static class EventCallbackExtensions {
12+
public static EventCallbackDebouncer<T> GetDebouncer<T>(this EventCallback<T> callback, int debounceMs) => new(callback, debounceMs);
13+
public static EventCallbackDebouncer GetDebouncer(this EventCallback callback, int debounceMs) => new(callback, debounceMs);
14+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
using Microsoft.AspNetCore.Components;
5+
6+
namespace Tests.CodeOfChaos.Extensions.AspNetCore.EventCallbacks;
7+
// ---------------------------------------------------------------------------------------------------------------------
8+
// Code
9+
// ---------------------------------------------------------------------------------------------------------------------
10+
public class EventCallbackDebouncerGenericTests {
11+
private readonly EventCallbackFactory _factory = new();
12+
13+
[Test]
14+
public async Task GenericDebouncer_ShouldPassValueToCallback() {
15+
// Arrange
16+
string? receivedValue = null;
17+
EventCallback<string> callback = _factory.Create<string>(this, callback: value => {
18+
receivedValue = value;
19+
return Task.CompletedTask;
20+
});
21+
22+
// Act
23+
await using var debouncer = new EventCallbackDebouncer<string>(callback);
24+
await debouncer.InvokeDebouncedAsync("test");
25+
await Task.Delay(150);
26+
27+
// Assert
28+
await Assert.That(receivedValue).IsEqualTo("test");
29+
}
30+
31+
[Test]
32+
public async Task GenericDebouncer_DefaultValue_ShouldWork() {
33+
// Arrange
34+
string receivedValue = "initial";
35+
EventCallback<string> callback = _factory.Create<string>(this, callback: value => {
36+
receivedValue = value;
37+
return Task.CompletedTask;
38+
});
39+
40+
// Act
41+
await using var debouncer = new EventCallbackDebouncer<string>(callback);
42+
await debouncer.InvokeDebouncedAsync();
43+
await Task.Delay(150);
44+
45+
// Assert
46+
await Assert.That(receivedValue).IsNull();
47+
}
48+
49+
[Test]
50+
public async Task GenericDebouncer_MultipleValues_ShouldUseLastValue() {
51+
// Arrange
52+
string? receivedValue = null;
53+
EventCallback<string> callback = _factory.Create<string>(this, callback: value => {
54+
receivedValue = value;
55+
return Task.CompletedTask;
56+
});
57+
58+
// Act
59+
await using var debouncer = new EventCallbackDebouncer<string>(callback);
60+
await debouncer.InvokeDebouncedAsync("first");
61+
await debouncer.InvokeDebouncedAsync("second");
62+
await debouncer.InvokeDebouncedAsync("third");
63+
await Task.Delay(150);
64+
65+
// Assert
66+
await Assert.That(receivedValue).IsEqualTo("third");
67+
}
68+
69+
[Test]
70+
public async Task GenericDebouncer_ConcurrentValues_ShouldBeThreadSafe() {
71+
// Arrange
72+
var receivedValues = new List<string>();
73+
EventCallback<string> callback = _factory.Create<string>(this, callback: value => {
74+
receivedValues.Add(value);
75+
return Task.CompletedTask;
76+
});
77+
78+
// Act
79+
var debouncer = new EventCallbackDebouncer<string>(callback);
80+
IEnumerable<Task> tasks = Enumerable.Range(0, 10)
81+
.Select(i => debouncer.InvokeDebouncedAsync($"value{i}"));
82+
83+
await Task.WhenAll(tasks);
84+
await Task.Delay(150);
85+
86+
// Assert
87+
await Assert.That(receivedValues).HasCount().EqualTo(1);
88+
await debouncer.DisposeAsync();
89+
}
90+
91+
[Test]
92+
public async Task GenericDebouncer_AfterDispose_ShouldThrowObjectDisposedException() {
93+
// Arrange
94+
EventCallback<string> callback = _factory.Create<string>(this, callback: _ => Task.CompletedTask);
95+
var debouncer = new EventCallbackDebouncer<string>(callback);
96+
97+
// Act
98+
await debouncer.DisposeAsync();
99+
100+
// Assert
101+
await Assert.ThrowsAsync<ObjectDisposedException>(async () =>
102+
await debouncer.InvokeDebouncedAsync("test"));
103+
}
104+
105+
[Test]
106+
public async Task GenericDebouncer_MultipleDispose_ShouldBeIdempotent() {
107+
// Arrange
108+
EventCallback<string> callback = _factory.Create<string>(this, callback: _ => Task.CompletedTask);
109+
var debouncer = new EventCallbackDebouncer<string>(callback);
110+
111+
// Act & Assert
112+
await debouncer.DisposeAsync();
113+
await debouncer.DisposeAsync(); // Should not throw
114+
}
115+
}

0 commit comments

Comments
 (0)