Skip to content

Commit a3fccba

Browse files
committed
added helpers and tests for creating throttled and buffered throttled command factories
similar to Cmd.debounce
1 parent e377841 commit a3fccba

File tree

2 files changed

+189
-0
lines changed

2 files changed

+189
-0
lines changed

src/Fabulous.Tests/CmdTests.fs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,110 @@ type ``Cmd tests``() =
4242
Assert.AreEqual(2, messageCount)
4343
Assert.AreEqual(Some(NewValue 5), actualValue)
4444
}
45+
46+
[<Test>]
47+
member _.``Cmd.throttle issues message at specified intervals``() =
48+
async {
49+
let mutable messageCount = 0
50+
let mutable actualValue = None
51+
52+
let dispatch msg =
53+
messageCount <- messageCount + 1
54+
actualValue <- Some msg
55+
56+
let throttleCmd = Cmd.throttle 100 NewValue
57+
58+
throttleCmd 1 |> CmdTestsHelper.execute dispatch
59+
do! Async.Sleep 50
60+
throttleCmd 2 |> CmdTestsHelper.execute dispatch
61+
do! Async.Sleep 75
62+
throttleCmd 3 |> CmdTestsHelper.execute dispatch
63+
do! Async.Sleep 125
64+
65+
Assert.AreEqual(2, messageCount)
66+
Assert.AreEqual(Some(NewValue 3), actualValue)
67+
68+
throttleCmd 4 |> CmdTestsHelper.execute dispatch
69+
do! Async.Sleep 75
70+
throttleCmd 5 |> CmdTestsHelper.execute dispatch
71+
do! Async.Sleep 125
72+
73+
Assert.AreEqual(3, messageCount)
74+
Assert.AreEqual(Some(NewValue 4), actualValue)
75+
}
76+
77+
[<Test>]
78+
member _.``Cmd.throttle issues only one message per interval``() =
79+
async {
80+
let mutable messageCount = 0
81+
let mutable actualValue = None
82+
83+
let dispatch msg =
84+
messageCount <- messageCount + 1
85+
actualValue <- Some msg
86+
87+
let throttleCmd = Cmd.throttle 100 NewValue
88+
89+
throttleCmd 1 |> CmdTestsHelper.execute dispatch
90+
do! Async.Sleep 20
91+
throttleCmd 2 |> CmdTestsHelper.execute dispatch
92+
do! Async.Sleep 35
93+
throttleCmd 3 |> CmdTestsHelper.execute dispatch
94+
do! Async.Sleep 125
95+
96+
// Only the first message should have been dispatched
97+
Assert.AreEqual(1, messageCount)
98+
Assert.AreEqual(Some(NewValue 1), actualValue)
99+
}
100+
101+
[<Test>]
102+
member _.``Cmd.bufferedThrottle dispatches the first and most recent message within the specified interval``() =
103+
async {
104+
let mutable messageCount = 0
105+
let mutable actualValue = None
106+
107+
let dispatch msg =
108+
messageCount <- messageCount + 1
109+
actualValue <- Some msg
110+
111+
let throttleCmd = Cmd.bufferedThrottle 100 NewValue
112+
113+
throttleCmd 1 |> CmdTestsHelper.execute dispatch
114+
do! Async.Sleep 20
115+
throttleCmd 2 |> CmdTestsHelper.execute dispatch
116+
do! Async.Sleep 10
117+
throttleCmd 3 |> CmdTestsHelper.execute dispatch
118+
do! Async.Sleep 20
119+
throttleCmd 4 |> CmdTestsHelper.execute dispatch
120+
do! Async.Sleep 125
121+
122+
// Only the first and most recent message should be dispatched
123+
Assert.AreEqual(2, messageCount)
124+
Assert.AreEqual(Some(NewValue 4), actualValue)
125+
}
126+
127+
[<Test>]
128+
member _.``Cmd.bufferedThrottle dispatches the most recent message even if delayed``() =
129+
async {
130+
let mutable actualValue = None
131+
let mutable messageCount = 0
132+
133+
let dispatch msg =
134+
messageCount <- messageCount + 1
135+
actualValue <- Some msg
136+
137+
let throttleCmd = Cmd.bufferedThrottle 100 NewValue
138+
139+
throttleCmd 1 |> CmdTestsHelper.execute dispatch
140+
throttleCmd 2 |> CmdTestsHelper.execute dispatch
141+
142+
// Only the first message should have been dispatched
143+
Assert.AreEqual(1, messageCount)
144+
Assert.AreEqual(Some(NewValue 1), actualValue)
145+
146+
do! Async.Sleep 200 // Wait longer than the throttle interval
147+
148+
// the second message should have been dispatched delayed
149+
Assert.AreEqual(2, messageCount)
150+
Assert.AreEqual(Some(NewValue 2), actualValue)
151+
}

src/Fabulous/Cmd.fs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,85 @@ module Cmd =
229229
},
230230
cts.Token
231231
)) ]
232+
233+
/// <summary>Creates a factory for Commands that dispatch a message only
234+
/// if the factory produced no other Command within the specified interval.
235+
/// This limits how often a message is dispatched by ensuring to only dispatch once within a certain time interval
236+
/// and dropping messages that are produces during the cooldown.
237+
/// Useful for limiting how often a progress message is shown or preventing too many updates to a UI element in a short time.
238+
/// Note that this creates an object with internal state and is intended to be used per Program or longer-running background process
239+
/// rather than once per message in the update function.</summary>
240+
/// <param name="interval">The minimum time interval between two consecutive Command executions in milliseconds.</param>
241+
/// <param name="fn">Maps a factory input value to a message for dispatch.</param>
242+
/// <returns>A Command factory function that maps an input value to a "throttled" Command which dispatches a message (mapped from the value)
243+
/// if the minimum time interval has elapsed since the last Command execution; otherwise, it does nothing.</returns>
244+
let throttle (interval: int) (fn: 'value -> 'msg) : 'value -> Cmd<'msg> =
245+
let mutable lastDispatch = System.DateTime.MinValue
246+
247+
// return a factory function mapping input values to "throttled" Commands that only dispatch if enough time passed
248+
fun (value: 'value) ->
249+
[ fun dispatch ->
250+
let now = System.DateTime.UtcNow
251+
252+
// If the interval has elapsed since the last execution, dispatch the message
253+
if now - lastDispatch >= System.TimeSpan.FromMilliseconds(float interval) then
254+
lastDispatch <- now
255+
dispatch(fn value) ]
256+
257+
/// <summary>
258+
/// Creates a Command factory that dispatches the most recent message in a given interval - even if delayed.
259+
/// This makes it similar to <see cref="throttle"/> in that it rate-limits the message dispatch
260+
/// and similar to <see cref="debounce"/> in that it guarantees the last message (within the interval or in total) is dispatched.
261+
/// Helpful for scenarios where you want to throttle, but cannot risk losing the last message to throttling
262+
/// - like the last progress update that completes a progress.
263+
/// Note that this function creates an object with internal state and is intended to be used per Program or longer-running background process
264+
/// rather than once per message in the update function.
265+
/// </summary>
266+
/// <param name="interval">The minimum time interval between two consecutive Command executions in milliseconds.</param>
267+
/// <param name="fn">A function that maps a factory input value to a message for dispatch.</param>
268+
/// <returns>
269+
/// A Command factory function that maps an input value to a Command which dispatches a message (mapped from the value), either immediately
270+
/// or after a delay respecting the interval, while cancelling older commands if the factory produces another Command before the interval has elapsed.
271+
/// </returns>
272+
let bufferedThrottle (interval: int) (fn: 'value -> 'msg) : 'value -> Cmd<'msg> =
273+
let rateLimit = System.TimeSpan.FromMilliseconds(float interval)
274+
let funLock = obj() // ensures safe access to resources shared across different threads
275+
let mutable lastDispatch = System.DateTime.MinValue
276+
let mutable cts: CancellationTokenSource = null // if set, allows cancelling the last issued Command
277+
278+
// Return a factory function mapping input values to sleeper Commands with delayed dispatch of the most recent message
279+
fun (value: 'value) ->
280+
[ fun dispatch ->
281+
lock funLock (fun () ->
282+
let now = System.DateTime.UtcNow
283+
let elapsedSinceLastDispatch = now - lastDispatch
284+
285+
// If the interval has elapsed since the last dispatch, dispatch immediately
286+
if elapsedSinceLastDispatch >= rateLimit then
287+
dispatch(fn value)
288+
lastDispatch <- now
289+
else // schedule the dispatch for when the interval is up
290+
// cancel the last sleeper Command issued earlier from this factory
291+
if cts <> null then
292+
cts.Cancel()
293+
cts.Dispose()
294+
295+
// make cancellation available to the factory's next Command
296+
cts <- new CancellationTokenSource()
297+
298+
// asynchronously wait for the remaining time before dispatch
299+
Async.Start(
300+
async {
301+
do! Async.Sleep(rateLimit - elapsedSinceLastDispatch)
302+
303+
lock funLock (fun () ->
304+
dispatch(fn value)
305+
lastDispatch <- System.DateTime.UtcNow
306+
307+
// done; invalidate own cancellation token
308+
if cts <> null then
309+
cts.Dispose()
310+
cts <- null)
311+
},
312+
cts.Token
313+
)) ]

0 commit comments

Comments
 (0)