diff --git a/Sources/OneWay/AnyEffect.swift b/Sources/OneWay/AnyEffect.swift index 42ce4a1..eb9702f 100644 --- a/Sources/OneWay/AnyEffect.swift +++ b/Sources/OneWay/AnyEffect.swift @@ -46,6 +46,31 @@ public struct AnyEffect: Effect where Element: Sendable { /// Sends elements only after a specified time interval elapses between events. /// + /// First, create a Hashable ID that will be used to identify the debounce effect: + /// + /// ```swift + /// enum DebounceID { + /// case searchText + /// } + /// ``` + /// + /// Then, apply the `debounce` modifier using the defined ID: + /// + /// ```swift + /// func reduce(state: inout State, action: Action) -> AnyEffect { + /// switch action { + /// // ... + /// case let .search(text): + /// return .single { + /// let result = await api.request(text) + /// return .setResult(result) + /// } + /// .debounce(id: DebounceID.searchText, for: 0.5) + /// // ... + /// } + /// } + /// ``` + /// /// - Parameters: /// - id: The effect's identifier. /// - seconds: The duration for which the effect should wait before sending an element. @@ -54,16 +79,20 @@ public struct AnyEffect: Effect where Element: Sendable { id: some EffectID, for seconds: Double ) -> Self { + let base = base var copy = self copy.method = .register(id, cancelInFlight: true) - let values = copy.values copy.base = Effects.Sequence( operation: { send in guard !Task.isCancelled else { return } let NSEC_PER_SEC: Double = 1_000_000_000 let dueTime = NSEC_PER_SEC * seconds - try? await Task.sleep(nanoseconds: UInt64(dueTime)) - for await value in values { + do { + try await Task.sleep(nanoseconds: UInt64(dueTime)) + } catch { + return + } + for await value in base.values { guard !Task.isCancelled else { return } send(value) } @@ -74,6 +103,31 @@ public struct AnyEffect: Effect where Element: Sendable { /// Sends elements only after a specified time interval elapses between events. /// + /// First, create a Hashable ID that will be used to identify the debounce effect: + /// + /// ```swift + /// enum DebounceID { + /// case searchText + /// } + /// ``` + /// + /// Then, apply the `debounce` modifier using the defined ID: + /// + /// ```swift + /// func reduce(state: inout State, action: Action) -> AnyEffect { + /// switch action { + /// // ... + /// case let .search(text): + /// return .single { + /// let result = await api.request(text) + /// return .setResult(result) + /// } + /// .debounce(id: DebounceID.searchText, for: .milliseconds(500)) + /// // ... + /// } + /// } + /// ``` + /// /// - Parameters: /// - id: The effect's identifier. /// - dueTime: The duration for which the effect should wait before sending an element. @@ -85,14 +139,18 @@ public struct AnyEffect: Effect where Element: Sendable { for dueTime: C.Instant.Duration, clock: C = ContinuousClock() ) -> Self { + let base = base var copy = self copy.method = .register(id, cancelInFlight: true) - let values = copy.values copy.base = Effects.Sequence( operation: { send in guard !Task.isCancelled else { return } - try? await clock.sleep(for: dueTime) - for await value in values { + do { + try await clock.sleep(for: dueTime) + } catch { + return + } + for await value in base.values { guard !Task.isCancelled else { return } send(value) } diff --git a/Sources/OneWay/Effect.swift b/Sources/OneWay/Effect.swift index a6f6825..d67d8e9 100644 --- a/Sources/OneWay/Effect.swift +++ b/Sources/OneWay/Effect.swift @@ -80,11 +80,14 @@ public enum Effects { public var values: AsyncStream { AsyncStream { continuation in - Task(priority: priority) { + let task = Task(priority: priority) { let result = await operation() continuation.yield(result) continuation.finish() } + continuation.onTermination = { _ in + task.cancel() + } } } } @@ -111,10 +114,13 @@ public enum Effects { public var values: AsyncStream { AsyncStream { continuation in - Task(priority: priority) { + let task = Task(priority: priority) { await operation { continuation.yield($0) } continuation.finish() } + continuation.onTermination = { _ in + task.cancel() + } } } } @@ -141,14 +147,18 @@ public enum Effects { public var values: AsyncStream { AsyncStream { continuation in - Task(priority: priority) { + let task = Task(priority: priority) { for effect in effects { for await value in effect.values { + guard !Task.isCancelled else { break } continuation.yield(value) } } continuation.finish() } + continuation.onTermination = { _ in + task.cancel() + } } } } @@ -175,12 +185,13 @@ public enum Effects { public var values: AsyncStream { AsyncStream { continuation in - Task(priority: priority) { + let task = Task(priority: priority) { if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { await withDiscardingTaskGroup { group in for effect in effects { group.addTask { for await value in effect.values { + guard !Task.isCancelled else { break } continuation.yield(value) } } @@ -192,6 +203,7 @@ public enum Effects { for effect in effects { group.addTask { for await value in effect.values { + guard !Task.isCancelled else { break } continuation.yield(value) } } @@ -200,6 +212,9 @@ public enum Effects { continuation.finish() } } + continuation.onTermination = { _ in + task.cancel() + } } } } diff --git a/Sources/OneWay/Store.swift b/Sources/OneWay/Store.swift index b1e7812..db1d7e6 100644 --- a/Sources/OneWay/Store.swift +++ b/Sources/OneWay/Store.swift @@ -85,12 +85,11 @@ where R.Action: Sendable, R.State: Sendable & Equatable { guard !isProcessing else { return } isProcessing = true await Task.yield() - let count = actionQueue.count - for index in Int.zero ..< count { - let action = actionQueue[index] + for action in actionQueue { let taskID = TaskID() let effect = reducer.reduce(state: &state, action: action) let task = Task { [weak self, taskID] in + guard !Task.isCancelled else { return } for await value in effect.values { guard let self else { break } guard !Task.isCancelled else { break } @@ -102,14 +101,18 @@ where R.Action: Sendable, R.State: Sendable & Equatable { switch effect.method { case let .register(id, cancelInFlight): + let effectID = EffectIDWrapper(id) if cancelInFlight { - let taskIDs = cancellables[EffectIDWrapper(id), default: []] + let taskIDs = cancellables[effectID, default: []] taskIDs.forEach { removeTask($0) } + cancellables.removeValue(forKey: effectID) } - cancellables[EffectIDWrapper(id), default: []].insert(taskID) + cancellables[effectID, default: []].insert(taskID) case let .cancel(id): - let taskIDs = cancellables[EffectIDWrapper(id), default: []] + let effectID = EffectIDWrapper(id) + let taskIDs = cancellables[effectID, default: []] taskIDs.forEach { removeTask($0) } + cancellables.removeValue(forKey: effectID) case .none: break } diff --git a/Tests/OneWayTests/StoreTests.swift b/Tests/OneWayTests/StoreTests.swift index 913e200..e327c93 100644 --- a/Tests/OneWayTests/StoreTests.swift +++ b/Tests/OneWayTests/StoreTests.swift @@ -323,7 +323,7 @@ private struct TestReducer: Reducer { case .longTimeTask: return .single { - try! await clock.sleep(for: .seconds(200)) + try? await clock.sleep(for: .seconds(200)) return Action.response("Success") } .cancellable(EffectID.longTimeTask)