Skip to content

Commit

Permalink
Switch Debounce DispatcherQueueTimer extension to use ConditionalWeak…
Browse files Browse the repository at this point in the history
…Table

This prevents capture holding onto the timer for garbage collection, validated with a unit test which fails with the ConcurrentDictionary, but succeeds with the ConditionalWeakTable
Because of the new Trailing/Leading behavior, we need the table in order to know if something was scheduled, otherwise we'd need reflection if we only stored the event handler, which wouldn't be AOT compatible.
  • Loading branch information
michael-hawker committed Nov 26, 2024
1 parent f92be97 commit 312ae0f
Show file tree
Hide file tree
Showing 2 changed files with 58 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// See the LICENSE file in the project root for more information.

using System.Collections.Concurrent;
using System.Runtime.CompilerServices;


#if WINAPPSDK
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
Expand All @@ -17,8 +19,8 @@ namespace CommunityToolkit.WinUI;
/// </summary>
public static class DispatcherQueueTimerExtensions
{
// TODO: We should use a WeakReference here...
private static ConcurrentDictionary<DispatcherQueueTimer, Action> _debounceInstances = new ConcurrentDictionary<DispatcherQueueTimer, Action>();
//// https://learn.microsoft.com/dotnet/api/system.runtime.compilerservices.conditionalweaktable-2
private static ConditionalWeakTable<DispatcherQueueTimer, Action> _debounceInstances = new();

/// <summary>
/// <para>Used to debounce (rate-limit) an event. The action will be postponed and executed after the interval has elapsed. At the end of the interval, the function will be called with the arguments that were passed most recently to the debounced function. Useful for smoothing keyboard input, for instance.</para>
Expand Down Expand Up @@ -60,11 +62,11 @@ public static void Debounce(this DispatcherQueueTimer timer, Action action, Time
{
// If we have a _debounceInstance queued, then we were running in trailing mode,
// so if we now have the immediate flag, we should ignore this timer, and run immediately.
if (_debounceInstances.ContainsKey(timer))
if (_debounceInstances.TryGetValue(timer, out var _))
{
timeout = false;

_debounceInstances.Remove(timer, out var _);
_debounceInstances.Remove(timer);
}

// If we're in immediate mode then we only execute if the timer wasn't running beforehand
Expand All @@ -79,7 +81,7 @@ public static void Debounce(this DispatcherQueueTimer timer, Action action, Time
timer.Tick += Timer_Tick;

// Store/Update function
_debounceInstances.AddOrUpdate(timer, action, (k, v) => action);
_debounceInstances.AddOrUpdate(timer, action);
}

// Start the timer to keep track of the last call here.
Expand All @@ -94,8 +96,9 @@ private static void Timer_Tick(object sender, object e)
timer.Tick -= Timer_Tick;
timer.Stop();

if (_debounceInstances.TryRemove(timer, out Action? action))
if (_debounceInstances.TryGetValue(timer, out Action? action))
{
_debounceInstances.Remove(timer);
action?.Invoke();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

using CommunityToolkit.Tests;
using CommunityToolkit.Tooling.TestGen;
using CommunityToolkit.WinUI;

#if WINAPPSDK
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
Expand Down Expand Up @@ -327,4 +326,53 @@ public async Task DispatcherQueueTimer_Debounce_Leading_Switch_Trailing_Interrup
Assert.AreEqual(value3, triggeredValue, "Expected value to now be the last value provided.");
Assert.AreEqual(2, triggeredCount, "Expected to interrupt execution of 2nd request.");
}

[TestCategory("DispatcherQueueTimerExtensions")]
[UIThreadTestMethod]
public async Task DispatcherQueueTimer_Debounce_Trailing_Stop_Lifetime()
{
// Our test indicator
WeakReference? reference = null;

// Still need to capture this on our UI thread
DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();

await Task.Run(() =>
{
// This test checks the lifetime of the timer and if we hold a reference to it.
var debounceTimer = _queue.CreateTimer();

// Track the DispatcherQueueTimer object
reference = new WeakReference(debounceTimer, true);

var triggeredCount = 0;
string? triggeredValue = null;

var value = "He";
debounceTimer.Debounce(
() =>
{
triggeredCount++;
triggeredValue = value;
},
TimeSpan.FromMilliseconds(60));

// Stop the timer before it would fire, with our proper method to clean-up
debounceTimer.Stop();

Assert.AreEqual(false, debounceTimer.IsRunning, "Expected to stop the timer.");

debounceTimer = null;
});

// Now out of scope and see if GC cleans up
GC.Collect();
GC.WaitForPendingFinalizers();

// Clean-up any UI thread work
await CompositionTargetHelper.ExecuteAfterCompositionRenderingAsync(() => { });

Assert.IsNotNull(reference, "Didn't capture weak reference.");
Assert.IsNull(reference.Target, "Strong reference to DispatcherQueueTimer still exists.");
}
}

0 comments on commit 312ae0f

Please sign in to comment.