Skip to content

Commit 96b011c

Browse files
committed
Add debounce(id:for:clock:) to SideEffect
1 parent f9fc305 commit 96b011c

File tree

6 files changed

+106
-43
lines changed

6 files changed

+106
-43
lines changed

Examples/Github-App/Github-App/View/RootView.swift

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,47 @@ import SwiftUI
44
@Reducer
55
struct RootReducer {
66
enum ViewAction: Equatable {
7-
case onSearchButtonTapped
8-
case onTextChanged(String)
7+
case textChanged
98
}
109

1110
enum ReducerAction: Equatable {
1211
case fetchRepositoriesResponse(TaskResult<[Repository]>)
1312
case alert(Alert)
13+
case queryChangeDebounced
1414

1515
enum Alert: Equatable {
1616
case retry
1717
}
1818
}
1919

20+
enum CancelID {
21+
case fetchRequest
22+
}
23+
2024
@Dependency(\.repositoryClient.fetchRepositories) var fetchRepositories
25+
@Dependency(\.continuousClock) var clock
2126

2227
func reduce(into state: StateContainer<RootView>, action: Action) -> SideEffect<Self> {
2328
switch action {
24-
case .onSearchButtonTapped:
25-
state.isLoading = true
26-
return fetchRepositories(query: state.searchText)
27-
28-
case let .onTextChanged(text):
29-
if text.isEmpty {
29+
case .textChanged:
30+
if state.searchText.isEmpty {
3031
state.repositories = []
32+
return .none
33+
} else {
34+
return .send(.queryChangeDebounced)
35+
.debounce(
36+
id: CancelID.fetchRequest,
37+
for: .seconds(0.3),
38+
clock: clock
39+
)
3140
}
32-
return .none
41+
42+
case .queryChangeDebounced:
43+
guard !state.searchText.isEmpty else {
44+
return .none
45+
}
46+
state.isLoading = true
47+
return fetchRepositories(query: state.searchText)
3348

3449
case let .fetchRepositoriesResponse(.success(repositories)):
3550
state.isLoading = false
@@ -93,12 +108,12 @@ struct RootView: View {
93108
ProgressView()
94109
}
95110
}
96-
.searchable(text: $searchText)
97-
.onSubmit(of: .search) {
98-
send(.onSearchButtonTapped)
99-
}
100-
.onChange(of: searchText) { _, newValue in
101-
send(.onTextChanged(newValue))
111+
.searchable(
112+
text: $searchText,
113+
placement: .navigationBarDrawer
114+
)
115+
.onChange(of: searchText) { _, _ in
116+
send(.textChanged)
102117
}
103118
.alert(target: self, unwrapping: $alertState)
104119
}

Examples/Github-App/Github-AppTests/RootReducerTests.swift

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import XCTest
44

55
@MainActor
66
final class RootReducerTests: XCTestCase {
7-
func testSearchButtonTapped() async {
8-
let store = makeStore(
9-
repositoryClient: RepositoryClient(
10-
fetchRepositories: { _ in [.stub] }
11-
)
12-
)
7+
func testTextChanged() async {
8+
let store = RootView().testStore(viewState: .init(searchText: "text")) {
9+
$0.repositoryClient.fetchRepositories = { _ in [.stub] }
10+
$0.continuousClock = ImmediateClock()
11+
}
1312

14-
await store.send(.onSearchButtonTapped) {
13+
await store.send(.textChanged)
14+
await store.receive(.queryChangeDebounced) {
1515
$0.isLoading = true
1616
}
1717
await store.receive(.fetchRepositoriesResponse(.success([.stub]))) {
@@ -20,15 +20,15 @@ final class RootReducerTests: XCTestCase {
2020
}
2121
}
2222

23-
func testSearchButtonTappedWithFailure() async {
23+
func testTextChangedWithFailure() async {
2424
let error = CancellationError()
25-
let store = makeStore(
26-
repositoryClient: RepositoryClient(
27-
fetchRepositories: { _ in throw error }
28-
)
29-
)
25+
let store = RootView().testStore(viewState: .init(searchText: "text")) {
26+
$0.repositoryClient.fetchRepositories = { _ in throw error }
27+
$0.continuousClock = ImmediateClock()
28+
}
3029

31-
await store.send(.onSearchButtonTapped) {
30+
await store.send(.textChanged)
31+
await store.receive(.queryChangeDebounced) {
3232
$0.isLoading = true
3333
}
3434
await store.receive(.fetchRepositoriesResponse(.failure(error))) {
@@ -48,19 +48,12 @@ final class RootReducerTests: XCTestCase {
4848
}
4949
}
5050

51-
func testTextChanged() async {
51+
func testEmptyTextChanged() async {
5252
let store = RootView().testStore(viewState: .init(repositories: [.stub]))
53-
await store.send(.onTextChanged("test"))
54-
await store.send(.onTextChanged("")) {
53+
await store.send(.textChanged) {
5554
$0.repositories = []
5655
}
5756
}
58-
59-
func makeStore(repositoryClient: RepositoryClient) -> TestStore<RootReducer> {
60-
RootView().testStore(viewState: .init()) {
61-
$0.repositoryClient = repositoryClient
62-
}
63-
}
6457
}
6558

6659
extension Repository {

Sources/SimplexArchitecture/SideEffect.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ public struct SideEffect<Reducer: ReducerProtocol>: Sendable {
1818
case concurrentAction([Reducer.Action])
1919
case serialEffect([SideEffect<Reducer>])
2020
case concurrentEffect([SideEffect<Reducer>])
21+
indirect case debounce(base: Self, id: AnyHashable, sleep: () async throws -> Void)
2122
}
2223

2324
// The kind of side effect.
25+
@usableFromInline
2426
let kind: EffectKind
2527

2628
@usableFromInline
@@ -118,3 +120,28 @@ public extension SideEffect {
118120
.init(effectKind: .concurrentEffect(effects))
119121
}
120122
}
123+
124+
public extension SideEffect {
125+
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
126+
@inlinable
127+
func debounce(
128+
id: some Hashable,
129+
for duration: Duration,
130+
clock: any Clock<Duration>
131+
) -> Self {
132+
switch self.kind {
133+
case .none, .debounce:
134+
self
135+
default:
136+
.init(
137+
effectKind: .debounce(
138+
base: self.kind,
139+
id: AnyHashable(id),
140+
sleep: {
141+
try await clock.sleep(for: duration)
142+
}
143+
)
144+
)
145+
}
146+
}
147+
}

Sources/SimplexArchitecture/Store/Store+send.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ extension Store {
9191
return .never
9292
} else {
9393
let send = _send ?? makeSend(for: container)
94-
let tasks = runEffect(sideEffect, send: send)
94+
let tasks = runEffect(sideEffect.kind, send: send)
9595

9696
return reduce(tasks: tasks)
9797
}
@@ -128,10 +128,10 @@ extension Store {
128128
}
129129

130130
func runEffect(
131-
_ sideEffect: borrowing SideEffect<Reducer>,
131+
_ sideEffect: SideEffect<Reducer>.EffectKind,
132132
send: Send<Reducer>
133133
) -> [SendTask] {
134-
switch sideEffect.kind {
134+
switch sideEffect {
135135
case let .run(priority, operation, `catch`):
136136
let task = Task.withEffectContext(priority: priority ?? .medium) {
137137
do {
@@ -179,7 +179,7 @@ extension Store {
179179
case let .serialEffect(effects):
180180
let task = Task.detached {
181181
for effect in effects {
182-
await self.reduce(tasks: self.runEffect(effect, send: send)).wait()
182+
await self.reduce(tasks: self.runEffect(effect.kind, send: send)).wait()
183183
}
184184
}
185185
return [SendTask(task: task)]
@@ -189,12 +189,22 @@ extension Store {
189189
partialResult.append(
190190
SendTask(
191191
task: Task.detached {
192-
await self.reduce(tasks: self.runEffect(effect, send: send)).wait()
192+
await self.reduce(tasks: self.runEffect(effect.kind, send: send)).wait()
193193
}
194194
)
195195
)
196196
}
197197

198+
case let .debounce(base, id, sleep):
199+
cancellableTasks[id]?.cancel()
200+
let cancellableTask = Task.withEffectContext {
201+
try? await sleep()
202+
guard !Task.isCancelled else { return }
203+
await reduce(tasks: runEffect(base, send: send)).wait()
204+
}
205+
cancellableTasks.updateValue(cancellableTask, forKey: id)
206+
return [SendTask(task: cancellableTask)]
207+
198208
case .none:
199209
return []
200210
}

Sources/SimplexArchitecture/Store/Store.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ public final class Store<Reducer: ReducerProtocol> {
1818
// Buffer to store Actions recurrently invoked through SideEffect in a single Action sent from View
1919
@TestOnly
2020
var sentFromEffectActions: [ActionTransition<Reducer>] = []
21+
// If debounce or cancel is used in SideEffect, the task is stored here
22+
var cancellableTasks: [AnyHashable: Task<Void, Never>] = [:]
2123

2224
var _send: Send<Reducer>?
2325
var initialReducerState: (() -> Reducer.ReducerState)?
@@ -52,6 +54,12 @@ public final class Store<Reducer: ReducerProtocol> {
5254
self.initialReducerState = initialReducerState
5355
}
5456

57+
deinit {
58+
cancellableTasks.values.forEach { task in
59+
task.cancel()
60+
}
61+
}
62+
5563
@discardableResult
5664
@usableFromInline
5765
func setContainerIfNeeded(

Tests/SimplexArchitectureTests/StoreTests.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,13 @@ private struct TestView: View {
241241
EmptyView()
242242
}
243243
}
244+
245+
extension Store {
246+
@_disfavoredOverload
247+
func runEffect(
248+
_ sideEffect: SideEffect<Reducer>,
249+
send: Send<Reducer>
250+
) -> [SendTask] {
251+
runEffect(sideEffect.kind, send: send)
252+
}
253+
}

0 commit comments

Comments
 (0)