Effect.throttle
is used to throttle the output of effects that are associated with an identifier, based on a similar sort of concept to cancellable
and debounce
:
func throttle<S>(
id: AnyHashable,
for interval: S.SchedulerTimeType.Stride,
scheduler: S,
latest: Bool
) -> Effect where S: Scheduler
It's currently internal due to testing issues outlined in:
There are a few concerns I have which weren't discussed in this GitHub issue that I'm interested to hear others' feedback on.
When throttle
is called more than once with the same id
on Effect
s whose Output
s do not match, the following line can cause a crash:
let value = latest ? value : (throttleValues[id] as! Output? ?? value) // throttleValues is [AnyHashable: Any]
This test demonstrates how one could write code that would crash the application:
func testThrottleDifferentTypes() {
struct CancelToken: Hashable {}
let e1 = Just(1)
.prepend(2)
.eraseToEffect()
.throttle(id: CancelToken(), for: 1, scheduler: DispatchQueue.testScheduler, latest: false)
let e2 = Just("1")
.eraseToEffect()
.throttle(id: CancelToken(), for: 1, scheduler: DispatchQueue.testScheduler, latest: false)
e1.sink(receiveValue: { _ in })
e2.sink(receiveValue: { _ in })
scheduler.advance(by: 1)
}
One potential fix is to use a type other than AnyHashable
for id
that is generic over the Output
type, ensuring that casting will succeed e.g.
Define a new type struct ThrottleId<Key, Output>: Hashable {}
and modify throttle
signature:
func throttle<S, K>(
id: ThrottleId<K, Output>,
for interval: S.SchedulerTimeType.Stride,
scheduler: S,
latest: Bool
) -> Effect where S: Scheduler
Then you'd get the following usage:
Just(1)
.eraseToEffect()
.throttle(id: ThrottleId<CancelToken, Int>(), for: 1, scheduler: DispatchQueue.testScheduler, latest: false) // Does not compile if `Int` is changed to another type.
You could even (debatably) slightly improve the ergonomics:
struct ThrottleId<Key, Output>: Hashable {
init(_ : Key.Type, _ : Output.Type = Output.self) {}
}
Resulting in the following usage:
Just(1)
.eraseToEffect()
.throttle(id: ThrottleId(CancelToken.self), for: 1, scheduler: DispatchQueue.testScheduler, latest: false) // Does not compile if `Int` is changed to another type.
A similar crash crops up here: let throttleTime = throttleTimes[id] as! S.SchedulerTimeType?
if throttle
is called more than once with the same id
but different schedulers whose SchedulerTimeType
do not match. Again, the crash could be prevented moving the scheduler type into the key.
Neither of these solutions prevent the programmer from writing code that will throttle in unexpected ways, but they do at least prevent crashes.
There's also currently no locking around the throttleTimes
or throttleValues
dictionaries, but that seems to be slightly more straightforward and less open ended.