diff --git a/Sources/ComposableSubscriber/Effect+receive.swift b/Sources/ComposableSubscriber/Effect+receive.swift index 346f787..abe650f 100644 --- a/Sources/ComposableSubscriber/Effect+receive.swift +++ b/Sources/ComposableSubscriber/Effect+receive.swift @@ -11,6 +11,38 @@ extension Effect { } } + /// A convenience effect for sending an action that receives a task result from the given operation. + /// + /// ## Example + /// + /// ```swift + /// @Reducer + /// struct MyFeature { + /// ... + /// enum Action { + /// case receive(TaskResult) + /// case task + /// } + /// + /// @Dependency(\.numberClient) var numberClient + /// + /// var body: some Reducer { + /// Reduce { state, action in + /// switch action { + /// ... + /// case .task: + /// return .receive(action: \.receive) { + /// try await numberClient.numberFact() + /// } + /// } + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - action: The action to embed the task result in. + /// - operation: The operation to call to create the task result. @inlinable public static func receive( action toResult: CaseKeyPath>, @@ -19,6 +51,42 @@ extension Effect { .receive(operation: .case(AnyCasePath(toResult), operation)) } + /// A convenience effect for sending an action that receives a task result from the given operation and then + /// transforming the output of the operation. + /// + /// ## Example + /// + /// ```swift + /// @Reducer + /// struct MyFeature { + /// ... + /// enum Action { + /// case receive(TaskResult) + /// case task + /// } + /// + /// @Dependency(\.numberClient) var numberClient + /// + /// var body: some Reducer { + /// Reduce { state, action in + /// switch action { + /// ... + /// case .task: + /// return .receive(action: \.receive) { + /// try await numberClient.numberFact() + /// } transform: { number in + /// "The current number fact is: \(number)" + /// } + /// } + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - action: The action to embed the task result in. + /// - operation: The operation to call to create the task result. + /// - transform: The operation used to transform the output of the operation. @inlinable public static func receive( action toResult: CaseKeyPath>, @@ -30,71 +98,53 @@ extension Effect { } extension Effect where Action: ReceiveAction { - - @inlinable - public static func receive( - _ operation: @escaping @Sendable () async throws -> T, - transform: @escaping @Sendable (T) -> Action.ReceiveAction - ) -> Self { - .receive(operation: .case(AnyCasePath(unsafe: Action.receive), operation, transform)) - } + /// A convenience effect for sending an action that receives a task result from the given operation and then + /// transforming the output of the operation. + /// + /// ## Example + /// + /// ```swift + /// @Reducer + /// struct MyFeature { + /// ... + /// enum Action: ReceiveAction { + /// case receive(TaskResult) + /// case task + /// + /// @CasePathable + /// enum ReceiveAction { + /// case numberFact(String) + /// } + /// } + /// + /// @Dependency(\.numberClient) var numberClient + /// + /// var body: some Reducer { + /// Reduce { state, action in + /// switch action { + /// ... + /// case .task: + /// return .receive(\.numberFact) { + /// try await numberClient.numberFact() + /// } + /// } + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - action: The action to embed the task result in. + /// - operation: The operation to call to create the task result. @inlinable public static func receive( _ toReceiveAction: CaseKeyPath, _ operation: @escaping @Sendable () async throws -> T ) -> Self { - return .receive(operation) { - AnyCasePath(toReceiveAction).embed($0) - } - } -} - -@usableFromInline -struct ReceiveOperation { - - @usableFromInline - let embed: @Sendable (TaskResult) -> Action - - @usableFromInline - let operation: @Sendable () async throws -> Input - - @usableFromInline - let transform: @Sendable (Input) -> Result - - @usableFromInline - func callAsFunction(send: Send) async { - await send(embed( - TaskResult { try await operation() } - .map(transform) + self.receive(operation: .case( + AnyCasePath(toReceiveAction), + operation )) } - - @usableFromInline - static func `case`( - _ casePath: AnyCasePath>, - _ operation: @escaping @Sendable () async throws -> Input, - _ transform: @escaping @Sendable (Input) -> Result - ) -> Self { - .init(embed: { casePath.embed($0) }, operation: operation, transform: transform) - } -} - -extension ReceiveOperation where Input == Result { - - @usableFromInline - init( - embed: @escaping @Sendable (TaskResult) -> Action, - operation: @escaping @Sendable () async throws -> Input - ) { - self.init(embed: embed, operation: operation, transform: { $0 }) - } - - @usableFromInline - static func `case`( - _ casePath: AnyCasePath>, - _ operation: @escaping @Sendable () async throws -> Input - ) -> Self { - .init(embed: { casePath.embed($0) }, operation: operation) - } } diff --git a/Sources/ComposableSubscriber/Internal/ReceiveOperation.swift b/Sources/ComposableSubscriber/Internal/ReceiveOperation.swift new file mode 100644 index 0000000..7a5be9a --- /dev/null +++ b/Sources/ComposableSubscriber/Internal/ReceiveOperation.swift @@ -0,0 +1,78 @@ +import ComposableArchitecture + +// A container that holds onto the required data for embedding a task result into +// an action and optionally transforming the output. +@usableFromInline +struct ReceiveOperation { + + @usableFromInline + let embed: @Sendable (TaskResult) -> Action + + @usableFromInline + let operation: @Sendable () async throws -> Value + + @usableFromInline + let transform: @Sendable (Value) -> Result + + @usableFromInline + func callAsFunction(send: Send) async { + await send(embed( + TaskResult { try await operation() } + .map(transform) + )) + } + + @usableFromInline + static func `case`( + _ casePath: AnyCasePath>, + _ operation: @escaping @Sendable () async throws -> Value, + _ transform: @escaping @Sendable (Value) -> Result + ) -> Self { + .init(embed: { casePath.embed($0) }, operation: operation, transform: transform) + } + + @usableFromInline + static func `case`( + _ casePath: AnyCasePath>, + operation: @escaping @Sendable () async throws -> Value, + embedIn embedInCase: AnyCasePath + ) -> Self { + .case(casePath, operation) { + embedInCase.embed($0) + } + } +} + +extension ReceiveOperation where Value == Result { + + @usableFromInline + init( + embed: @escaping @Sendable (TaskResult) -> Action, + operation: @escaping @Sendable () async throws -> Value + ) { + self.init(embed: embed, operation: operation, transform: { $0 }) + } + + @usableFromInline + static func `case`( + _ casePath: AnyCasePath>, + _ operation: @escaping @Sendable () async throws -> Value + ) -> Self { + .init(embed: { casePath.embed($0) }, operation: operation) + } +} + +extension ReceiveOperation where Action: ReceiveAction, Result == Action.ReceiveAction { + + @usableFromInline + static func `case`( + _ embedInCase: AnyCasePath, + _ operation: @escaping @Sendable () async throws -> Value + ) -> Self { + .case( + AnyCasePath(unsafe: Action.receive), + operation: operation, + embedIn: embedInCase + ) + } +} diff --git a/Sources/ComposableSubscriber/OnFailAction.swift b/Sources/ComposableSubscriber/OnFailAction.swift index b8c3fcd..ff99cc9 100644 --- a/Sources/ComposableSubscriber/OnFailAction.swift +++ b/Sources/ComposableSubscriber/OnFailAction.swift @@ -2,13 +2,25 @@ import ComposableArchitecture import Foundation import OSLog +/// Handle failures, generally used with actions that accept a task result. +/// +/// This is used in some of the higher order reducers to describe how they should handle failures. public enum OnFailAction { + + /// Throw a runtime warning and optionally log the error. case fail(prefix: String? = nil, log: (@Sendable (String) -> Void)? = nil) + + /// Ignore the error. + case ignore + + /// Handle the error, generally used to set the error on your state. case operation((inout State, Error) -> Effect) @usableFromInline func callAsFunction(state: inout State, error: Error) -> Effect { switch self { + case .ignore: + return .none case let .fail(prefix, log): if let prefix { return .fail(prefix: prefix, error: error, log: log) @@ -17,6 +29,7 @@ public enum OnFailAction { } case let .operation(handler): return handler(&state, error) + } } diff --git a/Sources/ComposableSubscriber/ReceiveAction.swift b/Sources/ComposableSubscriber/ReceiveAction.swift index 74e0f80..c535ee5 100644 --- a/Sources/ComposableSubscriber/ReceiveAction.swift +++ b/Sources/ComposableSubscriber/ReceiveAction.swift @@ -1,9 +1,20 @@ import ComposableArchitecture +/// An action type that exposes a `receive` method that accepts task result, generally from +/// calling external dependencies. +/// +/// This allows for multiple receive actions to be nested under +/// one single action that handle the `failure` and `success` cases more conveniently +/// by using some of the higher order reducers provided by this package. public protocol ReceiveAction { + + /// The success cases. associatedtype ReceiveAction + + /// The root receive case that is used to handle the results. static func receive(_ result: TaskResult) -> Self + /// Extracts the result from the action. var result: TaskResult? { get } } diff --git a/Sources/ComposableSubscriber/ReceiveReducer.swift b/Sources/ComposableSubscriber/ReceiveReducer.swift new file mode 100644 index 0000000..32945ec --- /dev/null +++ b/Sources/ComposableSubscriber/ReceiveReducer.swift @@ -0,0 +1,85 @@ +import ComposableArchitecture +import Foundation + +/// A reducer that can handle `receive` actions when the action type implements the ``ReceiveAction`` protocol. +/// +/// This allows you to handle the `success` and `failure` cases. +/// +/// ## Example +/// ```swift +/// @Reducer +/// struct MyFeature { +/// ... +/// enum Action: ReceiveAction { +/// case receive(TaskResult) +/// +/// @CasePathable +/// enum ReceiveAction { +/// case numberFact(String) +/// } +/// +/// ... +/// } +/// +/// @Dependency(\.logger) var logger +/// +/// public var body: some ReducerOf { +/// ReceiveReducer(onFail: .fail(logger: logger)) { state, action in +/// // Handle the success cases by switching on the receive action. +/// switch action { +/// case let .numberFact(fact): +/// state.numberFact = fact +/// return .none +/// } +/// } +/// ... +/// } +/// +public struct ReceiveReducer: Reducer { + + @usableFromInline + let toResult: (Action) -> TaskResult? + + @usableFromInline + let onFail: OnFailAction + + @usableFromInline + let onSuccess: (inout State, Action.ReceiveAction) -> Effect + + @usableFromInline + init( + internal toResult: @escaping (Action) -> TaskResult?, + onFail: OnFailAction, + onSuccess: @escaping(inout State, Action.ReceiveAction) -> Effect + ) { + self.toResult = toResult + self.onFail = onFail + self.onSuccess = onSuccess + } + + @inlinable + public init( + onFail: OnFailAction = .ignore, + onSuccess: @escaping (inout State, Action.ReceiveAction) -> Effect + ) { + self.init( + internal: { + AnyCasePath(unsafe: Action.receive).extract(from: $0) + }, + onFail: onFail, + onSuccess: onSuccess + ) + } + + @inlinable + public func reduce(into state: inout State, action: Action) -> Effect { + guard let result = toResult(action) else { return .none } + switch result { + case let .failure(error): + return onFail(state: &state, error: error) + case let .success(value): + return onSuccess(&state, value) + } + } + +} diff --git a/Sources/ComposableSubscriber/Reducer+onReceive.swift b/Sources/ComposableSubscriber/Reducer+onReceive.swift deleted file mode 100644 index eb41d41..0000000 --- a/Sources/ComposableSubscriber/Reducer+onReceive.swift +++ /dev/null @@ -1,300 +0,0 @@ -import ComposableArchitecture -import OSLog - -extension Reducer { - - @usableFromInline - func onReceive( - action toReceiveAction: CaseKeyPath, - set setAction: SetAction - ) -> _OnReceiveReducer { - .init( - parent: self, - receiveAction: { AnyCasePath(toReceiveAction).extract(from: $0) }, - setAction: setAction - ) - } - - @inlinable - public func onReceive( - action toReceiveAction: CaseKeyPath, - set setAction: @escaping (inout State, V) -> Effect - ) -> _OnReceiveReducer { - self.onReceive( - action: toReceiveAction, - set: .operation(f: setAction) - ) - } - - @inlinable - public func onReceive( - action toReceiveAction: CaseKeyPath, - set setAction: @escaping (inout State, V) -> Void - ) -> _OnReceiveReducer { - self.onReceive( - action: toReceiveAction, - set: .operation(f: { state, value in - setAction(&state, value) - return .none - }) - ) - } - - @inlinable - public func onReceive( - action toReceiveAction: CaseKeyPath, - set toStateKeyPath: WritableKeyPath, - effect: Effect = .none - ) -> _OnReceiveReducer { - self.onReceive( - action: toReceiveAction, - set: .keyPath(toStateKeyPath, effect: effect) - ) - } - - @inlinable - public func onReceive( - action toReceiveAction: CaseKeyPath, - set toStateKeyPath: WritableKeyPath, - effect: Effect = .none - ) -> _OnReceiveReducer { - self.onReceive( - action: toReceiveAction, - set: .optionalKeyPath(toStateKeyPath, effect: effect) - ) - } - - @usableFromInline - func onReceive( - action toReceiveAction: CaseKeyPath>, - onFail: OnFailAction? = nil, - onSuccess setAction: SetAction - ) -> _OnReceiveReducer> { - self.onReceive(action: toReceiveAction) { state, result in - switch result { - case let .failure(error): - if let onFail { - return onFail(state: &state, error: error) - } - return .none - case let .success(value): - return setAction(state: &state, value: value) - } - } - } - - @inlinable - public func onReceive( - action toReceiveAction: CaseKeyPath>, - onSuccess setAction: @escaping (inout State, V) -> Void, - onFail: OnFailAction? = nil - ) -> _OnReceiveReducer> { - self.onReceive( - action: toReceiveAction, - onFail: onFail, - onSuccess: .operation(setAction) - ) - } - - @inlinable - public func onReceive( - action toReceiveAction: CaseKeyPath>, - set toStateKeyPath: WritableKeyPath, - onFail: OnFailAction? = nil, - effect: Effect = .none - ) -> _OnReceiveReducer> { - self.onReceive( - action: toReceiveAction, - onFail: onFail, - onSuccess: .keyPath(toStateKeyPath, effect: effect) - ) - } - - @inlinable - public func onReceive( - action toReceiveAction: CaseKeyPath>, - set toStateKeyPath: WritableKeyPath, - onFail: OnFailAction? = nil, - effect: Effect = .none - ) -> _OnReceiveReducer> { - self.onReceive( - action: toReceiveAction, - onFail: onFail, - onSuccess: .optionalKeyPath(toStateKeyPath, effect: effect) - ) - } - - @inlinable - public func receive( - on triggerAction: CaseKeyPath, - with receiveAction: CaseKeyPath>, - result resultHandler: @escaping @Sendable () async throws -> Value - ) -> _ReceiveOnTriggerReducer { - .init( - parent: self, - triggerAction: { AnyCasePath(triggerAction).extract(from: $0) }, - toReceiveAction: { AnyCasePath(receiveAction).embed($0) }, - resultHandler: resultHandler - ) - } - -} - -extension Reducer where Action: ReceiveAction { - @inlinable - public func receive( - on triggerAction: CaseKeyPath, - case embedCasePath: CaseKeyPath, - result resultHandler: @escaping @Sendable () async throws -> Value - ) -> _ReceiveOnTriggerReducer { - .init( - parent: self, - triggerAction: { AnyCasePath(triggerAction).extract(from: $0) }, - toReceiveAction: { AnyCasePath(unsafe: Action.receive).embed($0) }, - resultHandler: { - try await AnyCasePath(embedCasePath).embed( - resultHandler() - ) - } - ) - } -} - -public struct _OnReceiveReducer: Reducer { - - @usableFromInline - let parent: Parent - - @usableFromInline - let receiveAction: (Parent.Action) -> Value? - - @usableFromInline - let setAction: SetAction - - @usableFromInline - init( - parent: Parent, - receiveAction: @escaping (Parent.Action) -> Value?, - setAction: SetAction - ) { - self.parent = parent - self.receiveAction = receiveAction - self.setAction = setAction - } - - @inlinable - public func reduce( - into state: inout Parent.State, - action: Parent.Action - ) -> Effect { - let baseEffects = parent.reduce(into: &state, action: action) - - guard let value = receiveAction(action) else { - return baseEffects - } - - return .merge( - baseEffects, - setAction(state: &state, value: value) - ) - } -} - -public struct _ReceiveOnTriggerReducer< - Parent: Reducer, - TriggerAction, - Value ->: Reducer { - - @usableFromInline - let parent: Parent - - @usableFromInline - let triggerAction: @Sendable (Parent.Action) -> TriggerAction? - - @usableFromInline - let toReceiveAction: @Sendable (TaskResult) -> Parent.Action - - @usableFromInline - let resultHandler: @Sendable () async throws -> Value - - @usableFromInline - init( - parent: Parent, - triggerAction: @escaping @Sendable (Parent.Action) -> TriggerAction?, - toReceiveAction: @escaping @Sendable (TaskResult) -> Parent.Action, - resultHandler: @escaping @Sendable () async throws -> Value - ) { - self.parent = parent - self.triggerAction = triggerAction - self.toReceiveAction = toReceiveAction - self.resultHandler = resultHandler - } - - @inlinable - public func reduce( - into state: inout Parent.State, - action: Parent.Action) -> Effect - { - let baseEffects = parent.reduce(into: &state, action: action) - - guard triggerAction(action) != nil else { - return baseEffects - } - - return .merge( - baseEffects, - .receive(operation: .init(embed: toReceiveAction, operation: resultHandler)) - ) - } -} - -public struct ReceiveReducer: Reducer { - - @usableFromInline - let toResult: (Action) -> TaskResult? - - @usableFromInline - let onFail: OnFailAction? - - @usableFromInline - let onSuccess: (inout State, Action.ReceiveAction) -> Effect - - @inlinable - init( - internal toResult: @escaping (Action) -> TaskResult?, - onFail: OnFailAction?, - onSuccess: @escaping(inout State, Action.ReceiveAction) -> Effect - ) { - self.toResult = toResult - self.onFail = onFail - self.onSuccess = onSuccess - } - - @inlinable - public init( - onFail: OnFailAction? = nil, - onSuccess: @escaping (inout State, Action.ReceiveAction) -> Effect - ) { - self.init( - internal: { - AnyCasePath(unsafe: Action.receive).extract(from: $0) - }, - onFail: onFail, - onSuccess: onSuccess - ) - } - - @inlinable - public func reduce(into state: inout State, action: Action) -> Effect { - guard let result = toResult(action) else { return .none } - switch result { - case let .failure(error): - guard let onFail else { return .none } - return onFail(state: &state, error: error) - case let .success(value): - return onSuccess(&state, value) - } - } - -} diff --git a/Sources/ComposableSubscriber/Reducer+receive.swift b/Sources/ComposableSubscriber/Reducer+receive.swift new file mode 100644 index 0000000..f9c8b3f --- /dev/null +++ b/Sources/ComposableSubscriber/Reducer+receive.swift @@ -0,0 +1,149 @@ +import ComposableArchitecture + +extension Reducer { + + /// A higher order reducer that sends a receive action on a trigger action. + /// + /// ## Example + /// ```swift + /// + /// @Reducer + /// struct MyFeature { + /// ... + /// + /// enum Action { + /// case receive(TaskResult) + /// case task + /// } + /// + /// @Dependency(\.numberClient) var numberClient + /// + /// var body: some ReducerOf { + /// Reduce { state, action in + /// ... + /// } + /// .receive(on: \.task, with: \.receive) { + /// try await numberClient.fetchCurrentNumber() + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - triggerAction: The action that triggers calling the result handler. + /// - receiveAction: The action that receives the task result. + /// - resultHandler: The operations that is called that returns the value to be received. + /// + @inlinable + public func receive( + on triggerAction: CaseKeyPath, + with receiveAction: CaseKeyPath>, + result resultHandler: @escaping @Sendable () async throws -> Value + ) -> _ReceiveOnTriggerReducer { + .init( + parent: self, + triggerAction: { AnyCasePath(triggerAction).extract(from: $0) }, + receiveOperation: .case(AnyCasePath(receiveAction), resultHandler) + ) + } + +} + +extension Reducer where Action: ReceiveAction { + /// A higher order reducer that sends a receive action on a trigger action. + /// + /// ## Example + /// ```swift + /// + /// @Reducer + /// struct MyFeature { + /// ... + /// + /// enum Action: ReceiveAction { + /// case receive(TaskResult) + /// case task + /// + /// @CasePathable + /// enum ReceiveAction { + /// case currentNumber(Int) + /// ... + /// } + /// } + /// + /// @Dependency(\.numberClient) var numberClient + /// + /// var body: some ReducerOf { + /// Reduce { state, action in + /// ... + /// } + /// .receive(on: \.task, case: \.currentNumber) { + /// try await numberClient.fetchCurrentNumber() + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - triggerAction: The action that triggers calling the result handler. + /// - embedCasePath: The case path to embed the result into. + /// - resultHandler: The operations that is called that returns the value to be received. + @inlinable + public func receive( + on triggerAction: CaseKeyPath, + case embedCasePath: CaseKeyPath, + result resultHandler: @escaping @Sendable () async throws -> Value + ) -> _ReceiveOnTriggerReducer { + .init( + parent: self, + triggerAction: { AnyCasePath(triggerAction).extract(from: $0) }, + receiveOperation: .case(AnyCasePath(embedCasePath), resultHandler) + ) + + } +} + +public struct _ReceiveOnTriggerReducer< + Parent: Reducer, + TriggerAction, + Value, + Result +>: Reducer { + + @usableFromInline + let parent: Parent + + @usableFromInline + let triggerAction: @Sendable (Parent.Action) -> TriggerAction? + + @usableFromInline + let receiveOperation: ReceiveOperation + + @usableFromInline + init( + parent: Parent, + triggerAction: @escaping @Sendable (Parent.Action) -> TriggerAction?, + receiveOperation: ReceiveOperation + ) { + self.parent = parent + self.triggerAction = triggerAction + self.receiveOperation = receiveOperation + } + + @inlinable + public func reduce( + into state: inout Parent.State, + action: Parent.Action) -> Effect + { + let baseEffects = parent.reduce(into: &state, action: action) + + guard triggerAction(action) != nil else { + return baseEffects + } + + return .merge( + baseEffects, + .receive(operation: receiveOperation) + ) + } +} + diff --git a/Sources/ComposableSubscriber/Reducer+subscribe.swift b/Sources/ComposableSubscriber/Reducer+subscribe.swift index 3e1b643..a7cce48 100644 --- a/Sources/ComposableSubscriber/Reducer+subscribe.swift +++ b/Sources/ComposableSubscriber/Reducer+subscribe.swift @@ -482,7 +482,7 @@ extension Reducer { } @usableFromInline -enum Operation { +enum SubscribeOperation { case action(action: AnyCasePath, animation: Animation?) case operation(f: (_ send: Send, Value) async throws -> Void) } @@ -513,7 +513,7 @@ public struct _SubscribeReducer @usableFromInline - let operation: Operation + let operation: SubscribeOperation @usableFromInline let transform: (StreamElement) -> Value @@ -523,7 +523,7 @@ public struct _SubscribeReducer, to stream: Stream, - with operation: Operation, + with operation: SubscribeOperation, transform: @escaping @Sendable (StreamElement) -> Value ) { self.parent = parent diff --git a/Tests/swift-composable-subscriberTests/TCAExtrasTests.swift b/Tests/swift-composable-subscriberTests/TCAExtrasTests.swift index aeb6600..30435ba 100644 --- a/Tests/swift-composable-subscriberTests/TCAExtrasTests.swift +++ b/Tests/swift-composable-subscriberTests/TCAExtrasTests.swift @@ -10,7 +10,6 @@ struct NumberClient { func currentNumber(fail: Bool = false) async throws -> Int { if fail { - struct CurrentNumberError: Error { } throw CurrentNumberError() } return try await currentNumber() @@ -18,6 +17,8 @@ struct NumberClient { } +struct CurrentNumberError: Error { } + extension NumberClient: TestDependencyKey { static var live: NumberClient { @@ -68,15 +69,22 @@ struct ReducerWithArg { @Dependency(\.numberClient) var numberClient var body: some Reducer { + Reduce { state, action in + switch action { + case let .receive(currentNumber): + state.currentNumber = currentNumber + return .none - EmptyReducer() - .onReceive(action: \.receive, set: \.currentNumber) - .subscribe( - to: numberClient.numberStreamWithArg, - using: \.number, - on: \.task, - with: \.receive - ) + case .task: + return .none + } + } + .subscribe( + to: numberClient.numberStreamWithArg, + using: \.number, + on: \.task, + with: \.receive + ) } } @@ -85,20 +93,28 @@ struct ReducerWithTransform { typealias State = NumberState typealias Action = NumberAction - + @Dependency(\.numberClient) var numberClient - + var body: some Reducer { - EmptyReducer() - .onReceive(action: \.receive, set: \.currentNumber) - .subscribe( - to: numberClient.numberStreamWithArg, - using: \.number, - on: \.task, - with: \.receive - ) { - $0 * 2 + Reduce { state, action in + switch action { + case let .receive(currentNumber): + state.currentNumber = currentNumber + return .none + + case .task: + return .none } + } + .subscribe( + to: numberClient.numberStreamWithArg, + using: \.number, + on: \.task, + with: \.receive + ) { + $0 * 2 + } } } @@ -134,6 +150,44 @@ struct ReducerWithReceiveAction { } +@Reducer +struct FailingReducer { + + struct State: Equatable { + static func == (lhs: FailingReducer.State, rhs: FailingReducer.State) -> Bool { + lhs.error?.localizedDescription == rhs.error?.localizedDescription + } + + var error: Error? + } + + enum Action: ReceiveAction { + + case receive(TaskResult) + case task + + @CasePathable + enum ReceiveAction { + case currentNumber(Int) + } + } + + @Dependency(\.numberClient) var numberClient + + public var body: some Reducer { + ReceiveReducer(onFail: .set(keyPath: \.error)) { state, action in + switch action { + case .currentNumber(_): + return .none + } + } + .receive(on: \.task, case: \.currentNumber) { + try await numberClient.currentNumber(fail: true) + } + } + +} + final class TCAExtrasTests: XCTestCase { @MainActor @@ -189,5 +243,25 @@ final class TCAExtrasTests: XCTestCase { await task.cancel() await store.finish() } + + @MainActor + func testFailEffect() async throws { + + let store = TestStore( + initialState: FailingReducer.State(), + reducer: FailingReducer.init + ) { + $0.numberClient = .live + } + + let task = await store.send(.task) + await store.receive(\.receive.failure) { + $0.error = CurrentNumberError() + } + + await task.cancel() + await store.finish() + } + }