Skip to content

Commit 96cdc7e

Browse files
Add Effect.task for wrapping units of async/await work (#715)
* wip * wip * wip * wip * wip * wip * wip * wip * wip * docs * clean up * Remove bad test Co-authored-by: Brandon Williams <[email protected]>
1 parent 692435d commit 96cdc7e

File tree

3 files changed

+208
-17
lines changed

3 files changed

+208
-17
lines changed

Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,44 @@ struct FactClient {
88
struct Error: Swift.Error, Equatable {}
99
}
1010

11+
// This is the "live" fact dependency that reaches into the outside world to fetch trivia.
12+
// Typically this live implementation of the dependency would live in its own module so that the
13+
// main feature doesn't need to compile it.
1114
extension FactClient {
12-
// This is the "live" fact dependency that reaches into the outside world to fetch trivia.
13-
// Typically this live implementation of the dependency would live in its own module so that the
14-
// main feature doesn't need to compile it.
15-
static let live = Self(
16-
fetch: { number in
17-
URLSession.shared.dataTaskPublisher(
18-
for: URL(string: "http://numbersapi.com/\(number)/trivia")!
19-
)
20-
.map { data, _ in String(decoding: data, as: UTF8.self) }
21-
.catch { _ in
22-
// Sometimes numbersapi.com can be flakey, so if it ever fails we will just
23-
// default to a mock response.
24-
Just("\(number) is a good number Brent")
25-
.delay(for: 1, scheduler: DispatchQueue.main)
15+
#if compiler(>=5.5)
16+
static let live = Self(
17+
fetch: { number in
18+
Effect.task {
19+
do {
20+
let (data, _) = try await URLSession.shared
21+
.data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!)
22+
return String(decoding: data, as: UTF8.self)
23+
} catch {
24+
await Task.sleep(NSEC_PER_SEC)
25+
return "\(number) is a good number Brent"
26+
}
27+
}
28+
.setFailureType(to: Error.self)
29+
.eraseToEffect()
2630
}
27-
.setFailureType(to: Error.self)
28-
.eraseToEffect()
29-
})
31+
)
32+
#else
33+
static let live = Self(
34+
fetch: { number in
35+
URLSession.shared.dataTaskPublisher(
36+
for: URL(string: "http://numbersapi.com/\(number)/trivia")!
37+
)
38+
.map { data, _ in String(decoding: data, as: UTF8.self) }
39+
.catch { _ in
40+
// Sometimes numbersapi.com can be flakey, so if it ever fails we will just
41+
// default to a mock response.
42+
Just("\(number) is a good number Brent")
43+
.delay(for: 1, scheduler: DispatchQueue.main)
44+
}
45+
.setFailureType(to: Error.self)
46+
.eraseToEffect()
47+
})
48+
#endif
3049
}
3150

3251
#if DEBUG

Sources/ComposableArchitecture/Beta/Concurrency.swift

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,109 @@ import Combine
22
import SwiftUI
33

44
#if compiler(>=5.5)
5+
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
6+
extension Effect {
7+
/// Wraps an asynchronous unit of work in an effect.
8+
///
9+
/// This function is useful for executing work in an asynchronous context and capture the result
10+
/// in an ``Effect`` so that the reducer, a non-asynchronous context, can process it.
11+
///
12+
/// ```swift
13+
/// Effect.task {
14+
/// guard case let .some((data, _)) = try? await URLSession.shared
15+
/// .data(from: .init(string: "http://numbersapi.com/42")!)
16+
/// else {
17+
/// return "Could not load"
18+
/// }
19+
///
20+
/// return String(decoding: data, as: UTF8.self)
21+
/// }
22+
/// ```
23+
///
24+
/// Note that due to the lack of tools to control the execution of asynchronous work in Swift,
25+
/// it is not recommended to use this function in reducers directly. Doing so will introduce
26+
/// thread hops into your effects that will make testing difficult. You will be responsible for
27+
/// adding explicit expectations to wait for small amounts of time so that effects can deliver
28+
/// their output.
29+
///
30+
/// Instead, this function is most helpful for calling `async`/`await` functions from the live
31+
/// implementation of dependencies, such as `URLSession.data`, `MKLocalSearch.start` and more.
32+
///
33+
/// - Parameters:
34+
/// - priority: Priority of the underlying task. If `nil`, the priority will come from
35+
/// `Task.currentPriority`.
36+
/// - operation: The operation to execute.
37+
/// - Returns: An effect wrapping the given asynchronous work.
38+
public static func task(
39+
priority: TaskPriority? = nil,
40+
operation: @escaping @Sendable () async -> Output
41+
) -> Self where Failure == Never {
42+
var task: Task<Void, Never>?
43+
return .future { callback in
44+
task = Task(priority: priority) {
45+
guard !Task.isCancelled else { return }
46+
let output = await operation()
47+
guard !Task.isCancelled else { return }
48+
callback(.success(output))
49+
}
50+
}
51+
.handleEvents(receiveCancel: { task?.cancel() })
52+
.eraseToEffect()
53+
}
54+
55+
/// Wraps an asynchronous unit of work in an effect.
56+
///
57+
/// This function is useful for executing work in an asynchronous context and capture the result
58+
/// in an ``Effect`` so that the reducer, a non-asynchronous context, can process it.
59+
///
60+
/// ```swift
61+
/// Effect.task {
62+
/// let (data, _) = try await URLSession.shared
63+
/// .data(from: .init(string: "http://numbersapi.com/42")!)
64+
///
65+
/// return String(decoding: data, as: UTF8.self)
66+
/// }
67+
/// ```
68+
///
69+
/// Note that due to the lack of tools to control the execution of asynchronous work in Swift,
70+
/// it is not recommended to use this function in reducers directly. Doing so will introduce
71+
/// thread hops into your effects that will make testing difficult. You will be responsible for
72+
/// adding explicit expectations to wait for small amounts of time so that effects can deliver
73+
/// their output.
74+
///
75+
/// Instead, this function is most helpful for calling `async`/`await` functions from the live
76+
/// implementation of dependencies, such as `URLSession.data`, `MKLocalSearch.start` and more.
77+
///
78+
/// - Parameters:
79+
/// - priority: Priority of the underlying task. If `nil`, the priority will come from
80+
/// `Task.currentPriority`.
81+
/// - operation: The operation to execute.
82+
/// - Returns: An effect wrapping the given asynchronous work.
83+
public static func task(
84+
priority: TaskPriority? = nil,
85+
operation: @escaping @Sendable () async throws -> Output
86+
) -> Self where Failure == Error {
87+
Deferred<Publishers.HandleEvents<PassthroughSubject<Output, Failure>>> {
88+
let subject = PassthroughSubject<Output, Failure>()
89+
let task = Task(priority: priority) {
90+
do {
91+
try Task.checkCancellation()
92+
let output = try await operation()
93+
try Task.checkCancellation()
94+
subject.send(output)
95+
subject.send(completion: .finished)
96+
} catch is CancellationError {
97+
subject.send(completion: .finished)
98+
} catch {
99+
subject.send(completion: .failure(error))
100+
}
101+
}
102+
return subject.handleEvents(receiveCancel: task.cancel)
103+
}
104+
.eraseToEffect()
105+
}
106+
}
107+
5108
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
6109
extension ViewStore {
7110
/// Sends an action into the store and then suspends while a piece of state is `true`.

Tests/ComposableArchitectureTests/EffectTests.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,4 +209,73 @@ final class EffectTests: XCTestCase {
209209
}
210210
}
211211
#endif
212+
213+
#if compiler(>=5.5)
214+
func testTask() {
215+
guard #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) else { return }
216+
217+
let expectation = self.expectation(description: "Complete")
218+
var result: Int?
219+
Effect<Int, Never>.task {
220+
expectation.fulfill()
221+
return 42
222+
}
223+
.sink(receiveValue: { result = $0 })
224+
.store(in: &self.cancellables)
225+
self.wait(for: [expectation], timeout: 0)
226+
XCTAssertEqual(result, 42)
227+
}
228+
229+
func testThrowingTask() {
230+
guard #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) else { return }
231+
232+
let expectation = self.expectation(description: "Complete")
233+
struct MyError: Error {}
234+
var result: Error?
235+
Effect<Int, Error>.task {
236+
expectation.fulfill()
237+
throw MyError()
238+
}
239+
.sink(
240+
receiveCompletion: {
241+
switch $0 {
242+
case .finished:
243+
XCTFail()
244+
case let .failure(error):
245+
result = error
246+
}
247+
},
248+
receiveValue: { _ in XCTFail() }
249+
)
250+
.store(in: &self.cancellables)
251+
self.wait(for: [expectation], timeout: 0)
252+
XCTAssertNotNil(result)
253+
}
254+
255+
func testCancellingTask() {
256+
guard #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) else { return }
257+
258+
@Sendable func work() async throws -> Int {
259+
var task: Task<Int, Error>!
260+
task = Task {
261+
await Task.sleep(NSEC_PER_MSEC)
262+
try Task.checkCancellation()
263+
return 42
264+
}
265+
task.cancel()
266+
return try await task.value
267+
}
268+
269+
let expectation = self.expectation(description: "Complete")
270+
Effect<Int, Error>.task {
271+
try await work()
272+
}
273+
.sink(
274+
receiveCompletion: { _ in expectation.fulfill() },
275+
receiveValue: { _ in XCTFail() }
276+
)
277+
.store(in: &self.cancellables)
278+
self.wait(for: [expectation], timeout: 0.2)
279+
}
280+
#endif
212281
}

0 commit comments

Comments
 (0)