Skip to content

Commit e520ba8

Browse files
Introduce Future extensions for bridging to async-await (#32)
Introduces extensions to `Future` that allow you to create a Future from an async closure. This gives us a nice bridge from async swift to Combine. Also introduce other initializers for convenience, and to make dispatch un-ambiguous: `Update(state:fx:transaction:)` `Update(state:animation:)` `Update(state:fx:animation:)` Fixes #26 by bridging async/await to Combine. ## Notes on design and tradeoffs This PR is an alternative to #27. As mentioned in #27 (comment), there are tradeoffs with a pure async/await-based approach to Fx - We do want combine-like publisher-subscription semantics for things like subconsciousnetwork/subconscious#473 (comment). Therefore we would need combine publisher machinery in Store anyway. - Additionally, we need a way to batch fx. If we pursue a pure async/await based approach, leaves us carrying an array of async closures on Update. - Why? Because we want a collection of Action-producing asynchronous tasks/processes, all which are all run in parallel. - An async sequence is no good, because we need to resolve in parallel, not in sequence. - So that leaves us with an array of async closures, which are run in store. - However, this semantics essentially describes Publishers. Going with an array of async closures does little except lose us the affordances of Combine Publishers. One motivation for #26 was to have an explicit semantics of cancellation for long-polling tasks, by forcing them to fold into state via the update function at each step. This is good practice for the normal case. However, there are cases where this is not desired, like subconsciousnetwork/subconscious#473 (comment), or for keyboard events. Also, it is possible to cancel long-polling publishers at their source through the mechanisms publishers provide. There is no need for Store to have cancellation semantics. A reasonable approach forward is to stick with Publishers, but to make it very convenient to bridge from async swift to Publishers. In particular, we want to make it very convenient to produce `Future<Action, Never>`, since these are one-shot Fx that always succeed and never fail, so they have the desired property that you do one fx, which produces one action, then fold into state with update. And so, we introduce an extension to `Future` which allows you to create a Future from an async closure.
1 parent 0d13440 commit e520ba8

4 files changed

Lines changed: 326 additions & 12 deletions

File tree

README.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,18 +113,22 @@ Effects are modeled as [Combine Publishers](https://developer.apple.com/document
113113
public typealias Fx<Action> = AnyPublisher<Action, Never>
114114
```
115115

116-
The most common way to produce effects is by exposing methods on `Environment` that produce effects publishers. For example, an asynchronous call to an authentication API service might be implemented in `Environment`, where an effects publisher is used to signal whether authentication was successful.
116+
You can produce effects by exposing services or methods on `Environment` that produce Combine publishers.
117+
118+
Another common approach is to make the environment (or some of its services) [actors](https://developer.apple.com/documentation/swift/actor). This has the advantage of getting work off the main thread.
117119

118120
```swift
119-
struct Environment {
121+
actor Environment {
120122
// ...
121-
func authenticate(credentials: Credentials) -> AnyPublisher<Action, Never> {
122-
// ...
123+
func authenticate(credentials: Credentials) async -> Action {
124+
// ...
123125
}
124126
}
125127
```
126128

127-
You can subscribe to an effects publisher by returning it as part of an Update:
129+
You can then wrap actor method calls in publishers. ObservableStore provides a helpful extension for this that allows you to construct a [Combine Future](https://developer.apple.com/documentation/combine/future) from an async closure.
130+
131+
Here's an example of creating an effect using an environment actor and returning it as part of the update:
128132

129133
```swift
130134
func update(
@@ -135,10 +139,12 @@ func update(
135139
switch action {
136140
// ...
137141
case .authenticate(let credentials):
138-
return Update(
139-
state: state,
140-
fx: environment.authenticate(credentials: credentials)
141-
)
142+
let fx = Future {
143+
await environment.authenticate(credentials: credentials)
144+
}
145+
.eraseToAnyPublisher()
146+
147+
return Update(state: state, fx: fx)
142148
}
143149
}
144150
```

Sources/ObservableStore/ObservableStore.swift

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,30 @@ public struct Update<Model: ModelProtocol> {
111111

112112
public init(
113113
state: Model,
114-
fx: Fx<Model.Action> = Empty(completeImmediately: true)
115-
.eraseToAnyPublisher(),
116-
transaction: Transaction? = nil
114+
fx: Fx<Model.Action>,
115+
transaction: Transaction?
117116
) {
118117
self.state = state
119118
self.fx = fx
120119
self.transaction = transaction
121120
}
122121

122+
public init(state: Model, animation: Animation? = nil) {
123+
self.state = state
124+
self.fx = Empty(completeImmediately: true).eraseToAnyPublisher()
125+
self.transaction = Transaction(animation: animation)
126+
}
127+
128+
public init(
129+
state: Model,
130+
fx: Fx<Model.Action>,
131+
animation: Animation? = nil
132+
) {
133+
self.state = state
134+
self.fx = fx
135+
self.transaction = Transaction(animation: animation)
136+
}
137+
123138
/// Merge existing fx together with new fx.
124139
/// - Returns a new `Update`
125140
public func mergeFx(_ fx: Fx<Model.Action>) -> Update<Model> {
@@ -382,3 +397,83 @@ extension StoreProtocol {
382397
)
383398
}
384399
}
400+
401+
/// Create a Combine Future from an async closure that never fails.
402+
/// Async actions are run in a task and fulfil the future's promise.
403+
///
404+
/// This convenience init makes it easy to bridge async/await to Combine.
405+
/// You can call `.eraseToAnyPublisher()` on the resulting future to make it
406+
/// an `Fx`.
407+
public extension Future where Failure == Never {
408+
convenience init(
409+
priority: TaskPriority? = nil,
410+
operation: @escaping () async -> Output
411+
) {
412+
self.init { promise in
413+
Task(priority: priority) {
414+
let value = await operation()
415+
promise(.success(value))
416+
}
417+
}
418+
}
419+
}
420+
421+
/// Create a Combine Future from an async closure that never fails.
422+
/// Async actions are run in a detached task and fulfil the future's promise.
423+
///
424+
/// This convenience init makes it easy to bridge async/await to Combine.
425+
/// You can call `.eraseToAnyPublisher()` on the resulting future to make it
426+
/// an `Fx`.
427+
public extension Future where Failure == Never {
428+
static func detached(
429+
priority: TaskPriority? = nil,
430+
operation: @escaping () async -> Output
431+
) -> Self {
432+
self.init { promise in
433+
Task.detached(priority: priority) {
434+
let value = await operation()
435+
promise(.success(value))
436+
}
437+
}
438+
}
439+
}
440+
441+
/// Create a Combine Future from a throwing async closure.
442+
/// Async actions are run in a task and fulfil the future's promise.
443+
public extension Future where Failure == Error {
444+
convenience init(
445+
priority: TaskPriority? = nil,
446+
operation: @escaping () async throws -> Output
447+
) {
448+
self.init { promise in
449+
Task(priority: priority) {
450+
do {
451+
let value = try await operation()
452+
promise(.success(value))
453+
} catch {
454+
promise(.failure(error))
455+
}
456+
}
457+
}
458+
}
459+
}
460+
461+
/// Create a Combine Future from a throwing async closure.
462+
/// Async actions are run in a detached task and fulfil the future's promise.
463+
public extension Future where Failure == Error {
464+
static func detached(
465+
priority: TaskPriority? = nil,
466+
operation: @escaping () async throws -> Output
467+
) -> Self {
468+
self.init { promise in
469+
Task.detached(priority: priority) {
470+
do {
471+
let value = try await operation()
472+
promise(.success(value))
473+
} catch {
474+
promise(.failure(error))
475+
}
476+
}
477+
}
478+
}
479+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//
2+
// FutureTests.swift
3+
//
4+
//
5+
// Created by Gordon Brander on 4/18/23.
6+
//
7+
8+
import XCTest
9+
import Combine
10+
@testable import ObservableStore
11+
12+
final class FutureTests: XCTestCase {
13+
var cancellables: Set<AnyCancellable> = Set()
14+
15+
override func setUp() {
16+
// Put setup code here. This method is called before the invocation
17+
// of each test method in the class.
18+
19+
// Clear cancellables from last test.
20+
cancellables = Set()
21+
}
22+
23+
enum TestServiceError: Error {
24+
case interruptedByIntergalacticHighwayProject
25+
}
26+
27+
actor TestService {
28+
func calculateMeaningOfLife() -> Int {
29+
return 42
30+
}
31+
32+
func failToCalculateMeaningOfLife() throws -> Int {
33+
throw TestServiceError.interruptedByIntergalacticHighwayProject
34+
}
35+
}
36+
37+
func testFutureAsyncExtension() throws {
38+
let service = TestService()
39+
40+
let expectation = XCTestExpectation(
41+
description: "Future completes successfully"
42+
)
43+
44+
Future {
45+
await service.calculateMeaningOfLife()
46+
}
47+
.sink(
48+
receiveCompletion: { completion in
49+
switch completion {
50+
case .finished:
51+
expectation.fulfill()
52+
}
53+
},
54+
receiveValue: { value in
55+
XCTAssertEqual(value, 42)
56+
}
57+
)
58+
.store(in: &cancellables)
59+
60+
wait(for: [expectation], timeout: 0.1)
61+
}
62+
63+
func testFutureDetachedAsyncExtension() throws {
64+
let service = TestService()
65+
66+
let expectation = XCTestExpectation(
67+
description: "Future completes successfully"
68+
)
69+
70+
Future.detached {
71+
await service.calculateMeaningOfLife()
72+
}
73+
.sink(
74+
receiveCompletion: { completion in
75+
switch completion {
76+
case .finished:
77+
expectation.fulfill()
78+
}
79+
},
80+
receiveValue: { value in
81+
XCTAssertEqual(value, 42)
82+
}
83+
)
84+
.store(in: &cancellables)
85+
86+
wait(for: [expectation], timeout: 0.1)
87+
}
88+
89+
func testFutureThrowingAsyncExtension() throws {
90+
let service = TestService()
91+
92+
let expectation = XCTestExpectation(
93+
description: "Future fails (intentional)"
94+
)
95+
96+
Future {
97+
try await service.failToCalculateMeaningOfLife()
98+
}
99+
.sink(
100+
receiveCompletion: { completion in
101+
switch completion {
102+
case .finished:
103+
XCTFail("Future finished with success result, but should have finished with failure result of type error")
104+
case .failure:
105+
expectation.fulfill()
106+
}
107+
},
108+
receiveValue: { value in
109+
XCTFail("Future should fail, and receiveValue should not be called")
110+
}
111+
)
112+
.store(in: &cancellables)
113+
114+
wait(for: [expectation], timeout: 0.1)
115+
}
116+
117+
func testFutureDetachedThrowingAsyncExtension() throws {
118+
let service = TestService()
119+
120+
let expectation = XCTestExpectation(
121+
description: "Future fails (intentional)"
122+
)
123+
124+
Future.detached {
125+
try await service.failToCalculateMeaningOfLife()
126+
}
127+
.sink(
128+
receiveCompletion: { completion in
129+
switch completion {
130+
case .finished:
131+
XCTFail("Future finished with success result, but should have finished with failure result of type error")
132+
case .failure:
133+
expectation.fulfill()
134+
}
135+
},
136+
receiveValue: { value in
137+
XCTFail("Future should fail, and receiveValue should not be called")
138+
}
139+
)
140+
.store(in: &cancellables)
141+
142+
wait(for: [expectation], timeout: 0.1)
143+
}
144+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//
2+
// UpdateTests.swift
3+
//
4+
//
5+
// Created by Gordon Brander on 4/10/23.
6+
//
7+
8+
import XCTest
9+
import SwiftUI
10+
import Combine
11+
@testable import ObservableStore
12+
13+
final class UpdateTests: XCTestCase {
14+
enum Action: Hashable {
15+
case a
16+
case b
17+
case c
18+
}
19+
20+
struct Model: ModelProtocol {
21+
typealias Environment = Void
22+
var value: String = ""
23+
24+
static func update(
25+
state: Self,
26+
action: Action,
27+
environment: Environment
28+
) -> Update<Self> {
29+
switch action {
30+
case .a:
31+
var model = state
32+
model.value = "a"
33+
return Update(state: model)
34+
case .b:
35+
var model = state
36+
model.value = "b"
37+
return Update(state: model)
38+
case .c:
39+
var model = state
40+
model.value = "c"
41+
return Update(state: model)
42+
}
43+
}
44+
}
45+
46+
/// This test does nothing except try all initializers so Swift
47+
/// will complain if any of our initializers are ambiguous.
48+
func testInitializers() {
49+
let _ = Update(
50+
state: Model(),
51+
fx: Just(.c).eraseToAnyPublisher(),
52+
transaction: Transaction(animation: .default)
53+
)
54+
55+
let _ = Update(state: Model())
56+
57+
let _ = Update(state: Model(), animation: .default)
58+
59+
let _ = Update(state: Model(), fx: Just(.c).eraseToAnyPublisher())
60+
61+
let _ = Update(
62+
state: Model(),
63+
fx: Just(.c).eraseToAnyPublisher(),
64+
animation: .default
65+
)
66+
67+
XCTAssertTrue(true)
68+
}
69+
}

0 commit comments

Comments
 (0)