Skip to content

Commit 692ead8

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

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

0 commit comments

Comments
 (0)