Skip to content

Commit 9f433c2

Browse files
Bring back throttle (#2368)
* Bring back throttle. * wip * docs * wip * remove print * Update Throttle.swift --------- Co-authored-by: Stephen Celis <[email protected]>
1 parent edaa5c0 commit 9f433c2

File tree

4 files changed

+278
-2
lines changed

4 files changed

+278
-2
lines changed

Sources/ComposableArchitecture/Documentation.docc/Articles/StackBasedNavigation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ other in a navigation stack.
481481
However, the more complex the features become, the more cumbersome testing their integration can be.
482482
By default, ``TestStore`` requires us to be exhaustive in our assertions. We must assert on how
483483
every piece of state changes, how every effect feeds data back into the system, and we must make
484-
sure that all effects finish by the end of the test (see <docs:Testing> for more info).
484+
sure that all effects finish by the end of the test (see <doc:Testing> for more info).
485485

486486
But ``TestStore`` also supports a form of testing known as "non-exhaustive testing" that allows you
487487
to assert on only the parts of the features that you actually care about (see

Sources/ComposableArchitecture/Documentation.docc/Articles/TreeBasedNavigation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ other.
558558
However, the more complex the features become, the more cumbersome testing their integration can be.
559559
By default, ``TestStore`` requires us to be exhaustive in our assertions. We must assert on how
560560
every piece of state changes, how every effect feeds data back into the system, and we must make
561-
sure that all effects finish by the end of the test (see <docs:Testing> for more info).
561+
sure that all effects finish by the end of the test (see <doc:Testing> for more info).
562562

563563
But ``TestStore`` also supports a form of testing known as "non-exhaustive testing" that allows you
564564
to assert on only the parts of the features that you actually care about (see
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import Combine
2+
import Dispatch
3+
import Foundation
4+
5+
extension Effect {
6+
/// Throttles an effect so that it only publishes one output per given interval.
7+
///
8+
/// The throttling of an effect is with respect to actions being sent into the store. So, if
9+
/// you return a throttled effect from an action that is sent with high frequency, the effect
10+
/// will be executed at most once per interval specified.
11+
///
12+
/// > Note: It is usually better to perform throttling logic in the _view_ in order to limit
13+
/// the number of actions sent into the system. Only use this operator if your reducer needs to
14+
/// layer on specialized logic for throttling. See <doc:Performance> for more information of why
15+
/// sending high-frequency actions into a store is typically not what you want to do.
16+
///
17+
/// - Parameters:
18+
/// - id: The effect's identifier.
19+
/// - interval: The interval at which to find and emit the most recent element, expressed in
20+
/// the time system of the scheduler.
21+
/// - scheduler: The scheduler you want to deliver the throttled output to.
22+
/// - latest: A boolean value that indicates whether to publish the most recent element. If
23+
/// `false`, the publisher emits the first element received during the interval.
24+
/// - Returns: An effect that emits either the most-recent or first element received during the
25+
/// specified interval.
26+
public func throttle<ID: Hashable, S: Scheduler>(
27+
id: ID,
28+
for interval: S.SchedulerTimeType.Stride,
29+
scheduler: S,
30+
latest: Bool
31+
) -> Self {
32+
switch self.operation {
33+
case .none:
34+
return .none
35+
36+
case .run:
37+
return .publisher { _EffectPublisher(self) }
38+
.throttle(id: id, for: interval, scheduler: scheduler, latest: latest)
39+
40+
case let .publisher(publisher):
41+
return .publisher {
42+
publisher
43+
.receive(on: scheduler)
44+
.flatMap { value -> AnyPublisher<Action, Never> in
45+
throttleLock.lock()
46+
defer { throttleLock.unlock() }
47+
48+
guard let throttleTime = throttleTimes[id] as! S.SchedulerTimeType? else {
49+
throttleTimes[id] = scheduler.now
50+
throttleValues[id] = nil
51+
return Just(value).eraseToAnyPublisher()
52+
}
53+
54+
let value = latest ? value : (throttleValues[id] as! Action? ?? value)
55+
throttleValues[id] = value
56+
57+
guard throttleTime.distance(to: scheduler.now) < interval else {
58+
throttleTimes[id] = scheduler.now
59+
throttleValues[id] = nil
60+
return Just(value).eraseToAnyPublisher()
61+
}
62+
63+
return Just(value)
64+
.delay(
65+
for: scheduler.now.distance(to: throttleTime.advanced(by: interval)),
66+
scheduler: scheduler
67+
)
68+
.handleEvents(
69+
receiveOutput: { _ in
70+
throttleLock.sync {
71+
throttleTimes[id] = scheduler.now
72+
throttleValues[id] = nil
73+
}
74+
}
75+
)
76+
.eraseToAnyPublisher()
77+
}
78+
}
79+
.cancellable(id: id, cancelInFlight: true)
80+
}
81+
}
82+
}
83+
84+
var throttleTimes: [AnyHashable: Any] = [:]
85+
var throttleValues: [AnyHashable: Any] = [:]
86+
let throttleLock = NSRecursiveLock()
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import Combine
2+
import ComposableArchitecture
3+
import XCTest
4+
5+
@MainActor
6+
final class EffectThrottleTests: BaseTCATestCase {
7+
let mainQueue = DispatchQueue.test
8+
9+
func testThrottleLatest_Publisher() async {
10+
let store = TestStore(initialState: ThrottleFeature.State()) {
11+
ThrottleFeature(id: #function, latest: true)
12+
} withDependencies: {
13+
$0.mainQueue = mainQueue.eraseToAnyScheduler()
14+
}
15+
16+
await store.send(.tap(1))
17+
await self.mainQueue.advance()
18+
await store.receive(.throttledResponse(1)) {
19+
$0.count = 1
20+
}
21+
22+
await store.send(.tap(2))
23+
await self.mainQueue.advance()
24+
await store.skipReceivedActions(strict: false)
25+
XCTAssertEqual(store.state.count, 1)
26+
27+
await self.mainQueue.advance(by: .seconds(0.25))
28+
await store.skipReceivedActions(strict: false)
29+
XCTAssertEqual(store.state.count, 1)
30+
31+
await store.send(.tap(3))
32+
await self.mainQueue.advance(by: .seconds(0.25))
33+
await store.skipReceivedActions(strict: false)
34+
XCTAssertEqual(store.state.count, 1)
35+
36+
await store.send(.tap(4))
37+
await self.mainQueue.advance(by: .seconds(0.25))
38+
await store.skipReceivedActions(strict: false)
39+
XCTAssertEqual(store.state.count, 1)
40+
41+
await store.send(.tap(5))
42+
await self.mainQueue.advance(by: .seconds(0.25))
43+
await store.receive(.throttledResponse(5)) {
44+
$0.count = 5
45+
}
46+
}
47+
48+
func testThrottleLatest_Async() async {
49+
let store = TestStore(initialState: ThrottleFeature.State()) {
50+
ThrottleFeature(id: #function, latest: true)
51+
} withDependencies: {
52+
$0.mainQueue = mainQueue.eraseToAnyScheduler()
53+
}
54+
55+
await store.send(.tap(1))
56+
await self.mainQueue.advance()
57+
await store.receive(.throttledResponse(1)) {
58+
$0.count = 1
59+
}
60+
61+
await store.send(.tap(2))
62+
await self.mainQueue.advance()
63+
await store.skipReceivedActions(strict: false)
64+
XCTAssertEqual(store.state.count, 1)
65+
66+
await self.mainQueue.advance(by: .seconds(0.25))
67+
await store.skipReceivedActions(strict: false)
68+
XCTAssertEqual(store.state.count, 1)
69+
70+
await store.send(.tap(3))
71+
await self.mainQueue.advance(by: .seconds(0.25))
72+
await store.skipReceivedActions(strict: false)
73+
XCTAssertEqual(store.state.count, 1)
74+
75+
await store.send(.tap(4))
76+
await self.mainQueue.advance(by: .seconds(0.25))
77+
await store.skipReceivedActions(strict: false)
78+
XCTAssertEqual(store.state.count, 1)
79+
80+
await store.send(.tap(5))
81+
await self.mainQueue.advance(by: .seconds(1))
82+
await store.receive(.throttledResponse(5)) {
83+
$0.count = 5
84+
}
85+
}
86+
87+
func testThrottleFirst_Publisher() async {
88+
let store = TestStore(initialState: ThrottleFeature.State()) {
89+
ThrottleFeature(id: #function, latest: false)
90+
} withDependencies: {
91+
$0.mainQueue = mainQueue.eraseToAnyScheduler()
92+
}
93+
94+
await store.send(.tap(1))
95+
await self.mainQueue.advance()
96+
await store.receive(.throttledResponse(1)) {
97+
$0.count = 1
98+
}
99+
100+
await store.send(.tap(2))
101+
await self.mainQueue.advance()
102+
await store.skipReceivedActions(strict: false)
103+
XCTAssertEqual(store.state.count, 1)
104+
105+
await self.mainQueue.advance(by: .seconds(0.25))
106+
await store.skipReceivedActions(strict: false)
107+
XCTAssertEqual(store.state.count, 1)
108+
109+
await store.send(.tap(3))
110+
await self.mainQueue.advance(by: .seconds(0.25))
111+
await store.skipReceivedActions(strict: false)
112+
XCTAssertEqual(store.state.count, 1)
113+
114+
await store.send(.tap(4))
115+
await self.mainQueue.advance(by: .seconds(0.25))
116+
await store.skipReceivedActions(strict: false)
117+
XCTAssertEqual(store.state.count, 1)
118+
119+
await store.send(.tap(5))
120+
await self.mainQueue.advance(by: .seconds(0.25))
121+
await store.receive(.throttledResponse(2)) {
122+
$0.count = 2
123+
}
124+
}
125+
126+
func testThrottleAfterInterval_Publisher() async {
127+
let store = TestStore(initialState: ThrottleFeature.State()) {
128+
ThrottleFeature(id: #function, latest: true)
129+
} withDependencies: {
130+
$0.mainQueue = mainQueue.eraseToAnyScheduler()
131+
}
132+
133+
await store.send(.tap(1))
134+
await self.mainQueue.advance()
135+
await store.receive(.throttledResponse(1)) {
136+
$0.count = 1
137+
}
138+
139+
await self.mainQueue.advance(by: .seconds(1))
140+
await store.send(.tap(2))
141+
await self.mainQueue.advance()
142+
await store.receive(.throttledResponse(2)) {
143+
$0.count = 2
144+
}
145+
}
146+
147+
func testThrottleEmitsFirstValueOnce_Publisher() async {
148+
let store = TestStore(initialState: ThrottleFeature.State()) {
149+
ThrottleFeature(id: #function, latest: true)
150+
} withDependencies: {
151+
$0.mainQueue = mainQueue.eraseToAnyScheduler()
152+
}
153+
154+
await store.send(.tap(1))
155+
await self.mainQueue.advance()
156+
await store.receive(.throttledResponse(1)) {
157+
$0.count = 1
158+
}
159+
160+
await self.mainQueue.advance(by: .seconds(1))
161+
await store.send(.tap(2))
162+
await self.mainQueue.advance()
163+
await store.receive(.throttledResponse(2)) {
164+
$0.count = 2
165+
}
166+
}
167+
}
168+
169+
struct ThrottleFeature: Reducer {
170+
struct State: Equatable {
171+
var count = 0
172+
}
173+
enum Action: Equatable {
174+
case tap(Int)
175+
case throttledResponse(Int)
176+
}
177+
let id: String
178+
let latest: Bool
179+
@Dependency(\.mainQueue) var mainQueue
180+
func reduce(into state: inout State, action: Action) -> Effect<Action> {
181+
switch action {
182+
case let .tap(value):
183+
return .send(.throttledResponse(value))
184+
.throttle(id: self.id, for: .seconds(1), scheduler: self.mainQueue, latest: self.latest)
185+
case let .throttledResponse(value):
186+
state.count = value
187+
return .none
188+
}
189+
}
190+
}

0 commit comments

Comments
 (0)