Skip to content

Commit 77485a9

Browse files
authored
AsyncPhaseAtom (#155)
* Add AsyncPhaseAtom * Add support for backward compatibility * Use AsyncPhaseAtom in one of examples * Add AsyncPhaseAtom to docs * Add documentation * Update README * Fix tests
1 parent 7a642f3 commit 77485a9

File tree

11 files changed

+420
-27
lines changed

11 files changed

+420
-27
lines changed

Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/SearchAtoms.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ struct SearchQueryAtom: StateAtom, Hashable {
77
}
88
}
99

10-
struct SearchMoviesAtom: ThrowingTaskAtom, Hashable {
10+
struct SearchMoviesAtom: AsyncPhaseAtom, Hashable {
1111
func value(context: Context) async throws -> [Movie] {
1212
let api = context.watch(APIClientAtom())
1313
let query = context.watch(SearchQueryAtom())

Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/SearchScreen.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Atoms
22
import SwiftUI
33

44
struct SearchScreen: View {
5-
@Watch(SearchMoviesAtom().phase)
5+
@Watch(SearchMoviesAtom())
66
var movies
77

88
@ViewContext
@@ -36,7 +36,7 @@ struct SearchScreen: View {
3636
.navigationTitle("Search Results")
3737
.listStyle(.insetGrouped)
3838
.refreshable {
39-
await context.refresh(SearchMoviesAtom().phase)
39+
await context.refresh(SearchMoviesAtom())
4040
}
4141
.sheet(item: $selectedMovie) { movie in
4242
DetailScreen(movie: movie)

Examples/Packages/iOS/Tests/ExampleMovieDBTests/ExampleMovieDBTests.swift

+10-14
Original file line numberDiff line numberDiff line change
@@ -118,37 +118,33 @@ final class ExampleMovieDBTests: XCTestCase {
118118
}
119119

120120
@MainActor
121-
func testSearchMoviesAtom() async throws {
121+
func testSearchMoviesAtom() async {
122122
let apiClient = MockAPIClient()
123123
let atom = SearchMoviesAtom()
124124
let context = AtomTestContext()
125125
let expected = PagedResponse.stub()
126-
let errorError = URLError(.badURL)
126+
let expectedError = URLError(.badURL)
127127

128128
context.override(APIClientAtom()) { _ in apiClient }
129129
apiClient.searchMoviesResponse = .success(expected)
130130

131131
context.watch(SearchQueryAtom())
132132

133-
let empty = try await context.refresh(atom).value
133+
let empty = await context.refresh(atom)
134134

135-
XCTAssertEqual(empty, [])
135+
XCTAssertEqual(empty.value, [])
136136

137137
context[SearchQueryAtom()] = "query"
138138

139-
let success = try await context.refresh(atom).value
139+
let success = await context.refresh(atom)
140140

141-
XCTAssertEqual(success, expected.results)
141+
XCTAssertEqual(success.value, expected.results)
142142

143-
apiClient.searchMoviesResponse = .failure(errorError)
143+
apiClient.searchMoviesResponse = .failure(expectedError)
144144

145-
do {
146-
_ = try await context.refresh(atom).value
147-
XCTFail("Should throw.")
148-
}
149-
catch {
150-
XCTAssertEqual(error as? URLError, errorError)
151-
}
145+
let failure = await context.refresh(atom)
146+
147+
XCTAssertEqual(failure.error as? URLError, expectedError)
152148
}
153149
}
154150

README.md

+54-2
Original file line numberDiff line numberDiff line change
@@ -514,11 +514,63 @@ struct MoviesView: View {
514514

515515
</details>
516516

517+
#### [AsyncPhaseAtom](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/asyncphaseatom)
518+
519+
| |Description|
520+
|:----------|:----------|
521+
|Summary |Provides an `AsyncPhase` value that represents a result of the given asynchronous throwable function.|
522+
|Output |`AsyncPhase<T, E: Error>` (`AsyncPhase<T, any Error>` in Swift 5)|
523+
|Use Case |Throwing or non-throwing asynchronous operation e.g. API call|
524+
525+
Note:
526+
The [typed throws](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0413-typed-throws.md) feature introduced in Swift 6 allows the `Failure` type of the produced `AsyncPhase` to be specified as any type or even non-throwing, but in Swift 5 without it, the `Failure` type is always be `any Error`.
527+
Here is a chart of the syntax in `typed throws` and the type of resulting `AsyncPhase`.
528+
529+
|Syntax |Shorthand |Produced |
530+
|:------------------|:------------------|:-------------------------|
531+
|`throws(E)` |`throws(E)` |`AsyncPhase<T, E>` |
532+
|`throws(any Error)`|`throws` |`AsyncPhase<T, any Error>`|
533+
|`throws(Never)` | |`AsyncPhase<T, Never>` |
534+
535+
<details><summary><code>📖 Example</code></summary>
536+
537+
```swift
538+
struct FetchTrendingSongsAtom: AsyncPhaseAtom, Hashable {
539+
func value(context: Context) async throws(FetchSongsError) -> [Song] {
540+
try await fetchTrendingSongs()
541+
}
542+
}
543+
544+
struct TrendingSongsView: View {
545+
@Watch(FetchTrendingSongsAtom())
546+
var phase
547+
548+
var body: some View {
549+
List {
550+
switch phase {
551+
case .success(let songs):
552+
ForEach(songs, id: \.id) { song in
553+
Text(song.title)
554+
}
555+
556+
case .failure(.noData):
557+
Text("There are no currently trending songs.")
558+
559+
case .failure(let error):
560+
Text(error.localizedDescription)
561+
}
562+
}
563+
}
564+
}
565+
```
566+
567+
</details>
568+
517569
#### [AsyncSequenceAtom](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/asyncsequenceatom)
518570

519571
| |Description|
520572
|:----------|:----------|
521-
|Summary |Provides a `AsyncPhase` value that represents asynchronous, sequential elements of the given `AsyncSequence`.|
573+
|Summary |Provides an `AsyncPhase` value that represents asynchronous, sequential elements of the given `AsyncSequence`.|
522574
|Output |`AsyncPhase<T, any Error>`|
523575
|Use Case |Handle multiple asynchronous values e.g. web-sockets|
524576

@@ -555,7 +607,7 @@ struct NotificationView: View {
555607

556608
| |Description|
557609
|:------------|:----------|
558-
|Summary |Provides a `AsyncPhase` value that represents sequence of values of the given `Publisher`.|
610+
|Summary |Provides an `AsyncPhase` value that represents sequence of values of the given `Publisher`.|
559611
|Output |`AsyncPhase<T, E: Error>`|
560612
|Use Case |Handle single or multiple asynchronous value(s) e.g. API call|
561613

+159
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/// An atom that provides an ``AsyncPhase`` value from the asynchronous throwable function.
2+
///
3+
/// The value produced by the given asynchronous throwable function will be converted into
4+
/// an enum representation ``AsyncPhase`` that changes when the process is done or thrown an error.
5+
///
6+
/// ## Output Value
7+
///
8+
/// ``AsyncPhase``<Self.Success, Self.Failure>
9+
///
10+
/// ## Example
11+
///
12+
/// ```swift
13+
/// struct AsyncTextAtom: AsyncPhaseAtom, Hashable {
14+
/// func value(context: Context) async throws -> String {
15+
/// try await Task.sleep(nanoseconds: 1_000_000_000)
16+
/// return "Swift"
17+
/// }
18+
/// }
19+
///
20+
/// struct DelayedTitleView: View {
21+
/// @Watch(AsyncTextAtom())
22+
/// var text
23+
///
24+
/// var body: some View {
25+
/// switch text {
26+
/// case .success(let text):
27+
/// Text(text)
28+
///
29+
/// case .suspending:
30+
/// Text("Loading")
31+
///
32+
/// case .failure:
33+
/// Text("Failed")
34+
/// }
35+
/// }
36+
/// ```
37+
///
38+
public protocol AsyncPhaseAtom: AsyncAtom where Produced == AsyncPhase<Success, Failure> {
39+
/// The type of success value that this atom produces.
40+
associatedtype Success
41+
42+
#if compiler(>=6)
43+
/// The type of errors that this atom produces.
44+
associatedtype Failure: Error
45+
46+
/// Asynchronously produces a value to be provided via this atom.
47+
///
48+
/// Values provided or errors thrown by this method are converted to the unified enum
49+
/// representation ``AsyncPhase``.
50+
///
51+
/// - Parameter context: A context structure to read, watch, and otherwise
52+
/// interact with other atoms.
53+
///
54+
/// - Throws: The error that occurred during the process of creating the resulting value.
55+
///
56+
/// - Returns: The process's result.
57+
@MainActor
58+
func value(context: Context) async throws(Failure) -> Success
59+
#else
60+
/// The type of errors that this atom produces.
61+
typealias Failure = any Error
62+
63+
/// Asynchronously produces a value to be provided via this atom.
64+
///
65+
/// Values provided or errors thrown by this method are converted to the unified enum
66+
/// representation ``AsyncPhase``.
67+
///
68+
/// - Parameter context: A context structure to read, watch, and otherwise
69+
/// interact with other atoms.
70+
///
71+
/// - Throws: The error that occurred during the process of creating the resulting value.
72+
///
73+
/// - Returns: The process's result.
74+
@MainActor
75+
func value(context: Context) async throws -> Success
76+
#endif
77+
}
78+
79+
public extension AsyncPhaseAtom {
80+
var producer: AtomProducer<Produced> {
81+
AtomProducer { context in
82+
let task = Task {
83+
#if compiler(>=6)
84+
do throws(Failure) {
85+
let value = try await context.transaction(value)
86+
87+
if !Task.isCancelled {
88+
context.update(with: .success(value))
89+
}
90+
}
91+
catch {
92+
if !Task.isCancelled {
93+
context.update(with: .failure(error))
94+
}
95+
}
96+
#else
97+
do {
98+
let value = try await context.transaction(value)
99+
100+
if !Task.isCancelled {
101+
context.update(with: .success(value))
102+
}
103+
}
104+
catch {
105+
if !Task.isCancelled {
106+
context.update(with: .failure(error))
107+
}
108+
}
109+
#endif
110+
}
111+
112+
context.onTermination = task.cancel
113+
return .suspending
114+
}
115+
}
116+
117+
var refreshProducer: AtomRefreshProducer<Produced> {
118+
AtomRefreshProducer { context in
119+
var phase = Produced.suspending
120+
121+
let task = Task {
122+
#if compiler(>=6)
123+
do throws(Failure) {
124+
let value = try await context.transaction(value)
125+
126+
if !Task.isCancelled {
127+
phase = .success(value)
128+
}
129+
}
130+
catch {
131+
if !Task.isCancelled {
132+
phase = .failure(error)
133+
}
134+
}
135+
#else
136+
do {
137+
let value = try await context.transaction(value)
138+
139+
if !Task.isCancelled {
140+
phase = .success(value)
141+
}
142+
}
143+
catch {
144+
if !Task.isCancelled {
145+
phase = .failure(error)
146+
}
147+
}
148+
#endif
149+
}
150+
151+
return await withTaskCancellationHandler {
152+
await task.value
153+
return phase
154+
} onCancel: {
155+
task.cancel()
156+
}
157+
}
158+
}
159+
}

Sources/Atoms/Atom/TaskAtom.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public protocol TaskAtom: AsyncAtom where Produced == Task<Success, Never> {
4646
/// - Parameter context: A context structure to read, watch, and otherwise
4747
/// interact with other atoms.
4848
///
49-
/// - Returns: A nonthrowing `Task` that produces asynchronous value.
49+
/// - Returns: The process's result.
5050
@MainActor
5151
func value(context: Context) async -> Success
5252
}

Sources/Atoms/Atom/ThrowingTaskAtom.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public protocol ThrowingTaskAtom: AsyncAtom where Produced == Task<Success, any
5050
///
5151
/// - Throws: The error that occurred during the process of creating the resulting value.
5252
///
53-
/// - Returns: A throwing `Task` that produces asynchronous value.
53+
/// - Returns: The process's result.
5454
@MainActor
5555
func value(context: Context) async throws -> Success
5656
}

Sources/Atoms/Atoms.docc/Atoms.md

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Building state by compositing atoms automatically optimizes rendering based on i
2020
- ``StateAtom``
2121
- ``TaskAtom``
2222
- ``ThrowingTaskAtom``
23+
- ``AsyncPhaseAtom``
2324
- ``AsyncSequenceAtom``
2425
- ``PublisherAtom``
2526
- ``ObservableObjectAtom``

Sources/Atoms/Core/Producer/AtomProducerContext.swift

+15-6
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,19 @@ internal struct AtomProducerContext<Value> {
3434
return body(context)
3535
}
3636

37-
func transaction<T>(_ body: @MainActor (AtomTransactionContext) async throws -> T) async rethrows -> T {
38-
transactionState.begin()
39-
let context = AtomTransactionContext(store: store, transactionState: transactionState)
40-
defer { transactionState.commit() }
41-
return try await body(context)
42-
}
37+
#if compiler(>=6)
38+
func transaction<T, E: Error>(_ body: @MainActor (AtomTransactionContext) async throws(E) -> T) async throws(E) -> T {
39+
transactionState.begin()
40+
let context = AtomTransactionContext(store: store, transactionState: transactionState)
41+
defer { transactionState.commit() }
42+
return try await body(context)
43+
}
44+
#else
45+
func transaction<T>(_ body: @MainActor (AtomTransactionContext) async throws -> T) async rethrows -> T {
46+
transactionState.begin()
47+
let context = AtomTransactionContext(store: store, transactionState: transactionState)
48+
defer { transactionState.commit() }
49+
return try await body(context)
50+
}
51+
#endif
4352
}

0 commit comments

Comments
 (0)