Skip to content

Commit 48362dd

Browse files
authored
Merge pull request #55 from Ryu0118/debounce
Add `debounce(id:for:clock:)` to SideEffect
2 parents f9fc305 + f4a7aba commit 48362dd

File tree

9 files changed

+172
-54
lines changed

9 files changed

+172
-54
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ struct RepositoryView: View {
3333

3434
init(repository: Repository) {
3535
self.repository = repository
36-
self.store = Store(
36+
store = Store(
3737
reducer: RepositoryReducer(),
3838
initialReducerState: RepositoryReducer.ReducerState(url: repository.url)
3939
)

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

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,51 @@ import SwiftUI
44
@Reducer
55
struct RootReducer {
66
enum ViewAction: Equatable {
7-
case onSearchButtonTapped
8-
case onTextChanged(String)
7+
case textChanged
8+
case onAppear
99
}
1010

1111
enum ReducerAction: Equatable {
1212
case fetchRepositoriesResponse(TaskResult<[Repository]>)
1313
case alert(Alert)
14+
case queryChangeDebounced
1415

1516
enum Alert: Equatable {
1617
case retry
1718
}
1819
}
1920

21+
enum CancelID {
22+
case response
23+
}
24+
2025
@Dependency(\.repositoryClient.fetchRepositories) var fetchRepositories
26+
@Dependency(\.continuousClock) var clock
2127

2228
func reduce(into state: StateContainer<RootView>, action: Action) -> SideEffect<Self> {
2329
switch action {
24-
case .onSearchButtonTapped:
25-
state.isLoading = true
26-
return fetchRepositories(query: state.searchText)
30+
case .onAppear:
31+
return fetchRepositories(query: "Swift")
2732

28-
case let .onTextChanged(text):
29-
if text.isEmpty {
33+
case .textChanged:
34+
if state.searchText.isEmpty {
3035
state.repositories = []
36+
return .none
37+
} else {
38+
return .send(.queryChangeDebounced)
39+
.debounce(
40+
id: CancelID.response,
41+
for: .seconds(0.3),
42+
clock: clock
43+
)
3144
}
32-
return .none
45+
46+
case .queryChangeDebounced:
47+
guard !state.searchText.isEmpty else {
48+
return .none
49+
}
50+
state.isLoading = true
51+
return fetchRepositories(query: state.searchText)
3352

3453
case let .fetchRepositoriesResponse(.success(repositories)):
3554
state.isLoading = false
@@ -93,14 +112,17 @@ struct RootView: View {
93112
ProgressView()
94113
}
95114
}
96-
.searchable(text: $searchText)
97-
.onSubmit(of: .search) {
98-
send(.onSearchButtonTapped)
99-
}
100-
.onChange(of: searchText) { _, newValue in
101-
send(.onTextChanged(newValue))
115+
.searchable(
116+
text: $searchText,
117+
placement: .navigationBarDrawer
118+
)
119+
.onChange(of: searchText) { _, _ in
120+
send(.textChanged)
102121
}
103122
.alert(target: self, unwrapping: $alertState)
123+
.onAppear {
124+
send(.onAppear)
125+
}
104126
}
105127
}
106128
}

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: 37 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,38 @@ public extension SideEffect {
118120
.init(effectKind: .concurrentEffect(effects))
119121
}
120122
}
123+
124+
public extension SideEffect {
125+
/// Turns an side effect into one that can be debounced.
126+
///
127+
/// debounce is an operator that plays only the last event in a sequence of SideEffects that have been issued for more than a certain time interval.
128+
/// To turn an effect into a debounce-able one you must provide an identifier, which is used to determine which in-flight effect should be canceled in order to start a new effect.
129+
///
130+
/// - Parameters:
131+
/// - id: The effect's identifier.
132+
/// - duration: The duration you want to debounce for.
133+
/// - clock: A clock conforming to the `Clock` protocol, used to measure the passing of time for the debounce duration.
134+
/// - Returns: A debounced version of the effect.
135+
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
136+
@inlinable
137+
func debounce(
138+
id: some Hashable,
139+
for duration: Duration,
140+
clock: any Clock<Duration>
141+
) -> Self {
142+
switch kind {
143+
case .none, .debounce:
144+
self
145+
default:
146+
.init(
147+
effectKind: .debounce(
148+
base: kind,
149+
id: AnyHashable(id),
150+
sleep: {
151+
try await clock.sleep(for: duration)
152+
}
153+
)
154+
)
155+
}
156+
}
157+
}

Sources/SimplexArchitecture/Store/Store+send.swift

Lines changed: 17 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,24 @@ 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 {
203+
return
204+
}
205+
await reduce(tasks: runEffect(base, send: send)).wait()
206+
}
207+
cancellableTasks.updateValue(cancellableTask, forKey: id)
208+
return [SendTask(task: cancellableTask)]
209+
198210
case .none:
199211
return []
200212
}

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(

Sources/SimplexArchitectureMacrosPlugin/Reducer/ReducerMacro+memberMacro.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public struct ReducerMacro: MemberMacro {
7272
name: .identifier("Action"),
7373
inheritanceClause: InheritanceClauseSyntax {
7474
(viewAction.inheritanceClause?.inheritedTypes ?? []) +
75-
[InheritedTypeSyntax(type: TypeSyntax(stringLiteral: "ActionProtocol"))]
75+
[InheritedTypeSyntax(type: TypeSyntax(stringLiteral: "ActionProtocol"))]
7676
}
7777
) {
7878
MemberBlockItemListSyntax {
@@ -92,7 +92,7 @@ public struct ReducerMacro: MemberMacro {
9292
reducerActionToAction
9393
}
9494
}
95-
.formatted().cast(EnumDeclSyntax.self)
95+
.formatted().cast(EnumDeclSyntax.self)
9696
).formatted().cast(DeclSyntax.self),
9797
].compactMap { $0 }
9898
}
@@ -204,33 +204,33 @@ public struct ReducerMacro: MemberMacro {
204204
private static func changeAnyNestedDeclToTypealias(action: EnumDeclSyntax, reducerAccessModifier: String) -> EnumDeclSyntax {
205205
action.with(
206206
\.memberBlock,
207-
action.memberBlock.with(
207+
action.memberBlock.with(
208208
\.members,
209-
MemberBlockItemListSyntax(
209+
MemberBlockItemListSyntax(
210210
action.memberBlock.members.compactMap {
211211
if let name = $0.hasName?.name.text,
212212
!name.contains(action.name.text)
213213
{
214214
$0.with(
215215
\.decl,
216-
DeclSyntax(
216+
DeclSyntax(
217217
TypeAliasDeclSyntax(
218218
modifiers: .init(arrayLiteral: DeclModifierSyntax(name: .identifier(reducerAccessModifier))),
219219
name: .identifier(name),
220220
initializer: TypeInitializerClauseSyntax(
221221
value: IdentifierTypeSyntax(name: .identifier("\(action.name.text).\(name)"))
222222
)
223223
)
224-
)
224+
)
225225
)
226226
} else {
227227
$0
228228
}
229229
}
230-
)
231-
)
232-
.formatted()
233-
.cast(MemberBlockSyntax.self)
230+
)
231+
)
232+
.formatted()
233+
.cast(MemberBlockSyntax.self)
234234
)
235235
}
236236

Tests/SimplexArchitectureTests/ReducerTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,18 @@ final class ReducerTests: XCTestCase {
148148
await testStore.receiveWithoutStateCheck(.decrement)
149149
await testStore.receiveWithoutStateCheck(.increment)
150150
}
151+
152+
func testDebounced() async {
153+
let testStore = TestView().testStore(
154+
viewState: .init()) {
155+
$0.continuousClock = ImmediateClock()
156+
}
157+
158+
await testStore.send(.debounced)
159+
await testStore.receive(.increment) {
160+
$0.count = 1
161+
}
162+
}
151163
}
152164

153165
struct TestDependency: DependencyKey {
@@ -195,6 +207,7 @@ private struct TestReducer {
195207
case testDependencies
196208
case runEffectsSerially
197209
case runEffectsConcurrently
210+
case debounced
198211
}
199212

200213
@Dependency(\.continuousClock) private var clock
@@ -281,6 +294,15 @@ private struct TestReducer {
281294
await send(.increment)
282295
}
283296
)
297+
298+
case .debounced:
299+
enum CancelID { case debounce }
300+
return .send(.increment)
301+
.debounce(
302+
id: CancelID.debounce,
303+
for: .seconds(0.3),
304+
clock: clock
305+
)
284306
}
285307
}
286308
}

0 commit comments

Comments
 (0)