Skip to content

Commit

Permalink
AsyncPhaseAtom (#155)
Browse files Browse the repository at this point in the history
* Add AsyncPhaseAtom

* Add support for backward compatibility

* Use AsyncPhaseAtom in one of examples

* Add AsyncPhaseAtom to docs

* Add documentation

* Update README

* Fix tests
  • Loading branch information
ra1028 authored Oct 9, 2024
1 parent 7a642f3 commit 77485a9
Show file tree
Hide file tree
Showing 11 changed files with 420 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct SearchQueryAtom: StateAtom, Hashable {
}
}

struct SearchMoviesAtom: ThrowingTaskAtom, Hashable {
struct SearchMoviesAtom: AsyncPhaseAtom, Hashable {
func value(context: Context) async throws -> [Movie] {
let api = context.watch(APIClientAtom())
let query = context.watch(SearchQueryAtom())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Atoms
import SwiftUI

struct SearchScreen: View {
@Watch(SearchMoviesAtom().phase)
@Watch(SearchMoviesAtom())
var movies

@ViewContext
Expand Down Expand Up @@ -36,7 +36,7 @@ struct SearchScreen: View {
.navigationTitle("Search Results")
.listStyle(.insetGrouped)
.refreshable {
await context.refresh(SearchMoviesAtom().phase)
await context.refresh(SearchMoviesAtom())
}
.sheet(item: $selectedMovie) { movie in
DetailScreen(movie: movie)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,37 +118,33 @@ final class ExampleMovieDBTests: XCTestCase {
}

@MainActor
func testSearchMoviesAtom() async throws {
func testSearchMoviesAtom() async {
let apiClient = MockAPIClient()
let atom = SearchMoviesAtom()
let context = AtomTestContext()
let expected = PagedResponse.stub()
let errorError = URLError(.badURL)
let expectedError = URLError(.badURL)

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

context.watch(SearchQueryAtom())

let empty = try await context.refresh(atom).value
let empty = await context.refresh(atom)

XCTAssertEqual(empty, [])
XCTAssertEqual(empty.value, [])

context[SearchQueryAtom()] = "query"

let success = try await context.refresh(atom).value
let success = await context.refresh(atom)

XCTAssertEqual(success, expected.results)
XCTAssertEqual(success.value, expected.results)

apiClient.searchMoviesResponse = .failure(errorError)
apiClient.searchMoviesResponse = .failure(expectedError)

do {
_ = try await context.refresh(atom).value
XCTFail("Should throw.")
}
catch {
XCTAssertEqual(error as? URLError, errorError)
}
let failure = await context.refresh(atom)

XCTAssertEqual(failure.error as? URLError, expectedError)
}
}

Expand Down
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,11 +514,63 @@ struct MoviesView: View {

</details>

#### [AsyncPhaseAtom](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/asyncphaseatom)

| |Description|
|:----------|:----------|
|Summary |Provides an `AsyncPhase` value that represents a result of the given asynchronous throwable function.|
|Output |`AsyncPhase<T, E: Error>` (`AsyncPhase<T, any Error>` in Swift 5)|
|Use Case |Throwing or non-throwing asynchronous operation e.g. API call|

Note:
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`.
Here is a chart of the syntax in `typed throws` and the type of resulting `AsyncPhase`.

|Syntax |Shorthand |Produced |
|:------------------|:------------------|:-------------------------|
|`throws(E)` |`throws(E)` |`AsyncPhase<T, E>` |
|`throws(any Error)`|`throws` |`AsyncPhase<T, any Error>`|
|`throws(Never)` | |`AsyncPhase<T, Never>` |

<details><summary><code>📖 Example</code></summary>

```swift
struct FetchTrendingSongsAtom: AsyncPhaseAtom, Hashable {
func value(context: Context) async throws(FetchSongsError) -> [Song] {
try await fetchTrendingSongs()
}
}

struct TrendingSongsView: View {
@Watch(FetchTrendingSongsAtom())
var phase

var body: some View {
List {
switch phase {
case .success(let songs):
ForEach(songs, id: \.id) { song in
Text(song.title)
}

case .failure(.noData):
Text("There are no currently trending songs.")

case .failure(let error):
Text(error.localizedDescription)
}
}
}
}
```

</details>

#### [AsyncSequenceAtom](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/asyncsequenceatom)

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

Expand Down Expand Up @@ -555,7 +607,7 @@ struct NotificationView: View {

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

Expand Down
159 changes: 159 additions & 0 deletions Sources/Atoms/Atom/AsyncPhaseAtom.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/// An atom that provides an ``AsyncPhase`` value from the asynchronous throwable function.
///
/// The value produced by the given asynchronous throwable function will be converted into
/// an enum representation ``AsyncPhase`` that changes when the process is done or thrown an error.
///
/// ## Output Value
///
/// ``AsyncPhase``<Self.Success, Self.Failure>
///
/// ## Example
///
/// ```swift
/// struct AsyncTextAtom: AsyncPhaseAtom, Hashable {
/// func value(context: Context) async throws -> String {
/// try await Task.sleep(nanoseconds: 1_000_000_000)
/// return "Swift"
/// }
/// }
///
/// struct DelayedTitleView: View {
/// @Watch(AsyncTextAtom())
/// var text
///
/// var body: some View {
/// switch text {
/// case .success(let text):
/// Text(text)
///
/// case .suspending:
/// Text("Loading")
///
/// case .failure:
/// Text("Failed")
/// }
/// }
/// ```
///
public protocol AsyncPhaseAtom: AsyncAtom where Produced == AsyncPhase<Success, Failure> {
/// The type of success value that this atom produces.
associatedtype Success

#if compiler(>=6)
/// The type of errors that this atom produces.
associatedtype Failure: Error

/// Asynchronously produces a value to be provided via this atom.
///
/// Values provided or errors thrown by this method are converted to the unified enum
/// representation ``AsyncPhase``.
///
/// - Parameter context: A context structure to read, watch, and otherwise
/// interact with other atoms.
///
/// - Throws: The error that occurred during the process of creating the resulting value.
///
/// - Returns: The process's result.
@MainActor
func value(context: Context) async throws(Failure) -> Success
#else
/// The type of errors that this atom produces.
typealias Failure = any Error

/// Asynchronously produces a value to be provided via this atom.
///
/// Values provided or errors thrown by this method are converted to the unified enum
/// representation ``AsyncPhase``.
///
/// - Parameter context: A context structure to read, watch, and otherwise
/// interact with other atoms.
///
/// - Throws: The error that occurred during the process of creating the resulting value.
///
/// - Returns: The process's result.
@MainActor
func value(context: Context) async throws -> Success
#endif
}

public extension AsyncPhaseAtom {
var producer: AtomProducer<Produced> {
AtomProducer { context in
let task = Task {
#if compiler(>=6)
do throws(Failure) {
let value = try await context.transaction(value)

if !Task.isCancelled {
context.update(with: .success(value))
}
}
catch {
if !Task.isCancelled {
context.update(with: .failure(error))
}
}
#else
do {
let value = try await context.transaction(value)

if !Task.isCancelled {
context.update(with: .success(value))
}
}
catch {
if !Task.isCancelled {
context.update(with: .failure(error))
}
}
#endif
}

context.onTermination = task.cancel
return .suspending
}
}

var refreshProducer: AtomRefreshProducer<Produced> {
AtomRefreshProducer { context in
var phase = Produced.suspending

let task = Task {
#if compiler(>=6)
do throws(Failure) {
let value = try await context.transaction(value)

if !Task.isCancelled {
phase = .success(value)
}
}
catch {
if !Task.isCancelled {
phase = .failure(error)
}
}
#else
do {
let value = try await context.transaction(value)

if !Task.isCancelled {
phase = .success(value)
}
}
catch {
if !Task.isCancelled {
phase = .failure(error)
}
}
#endif
}

return await withTaskCancellationHandler {
await task.value
return phase
} onCancel: {
task.cancel()
}
}
}
}
2 changes: 1 addition & 1 deletion Sources/Atoms/Atom/TaskAtom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public protocol TaskAtom: AsyncAtom where Produced == Task<Success, Never> {
/// - Parameter context: A context structure to read, watch, and otherwise
/// interact with other atoms.
///
/// - Returns: A nonthrowing `Task` that produces asynchronous value.
/// - Returns: The process's result.
@MainActor
func value(context: Context) async -> Success
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Atoms/Atom/ThrowingTaskAtom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public protocol ThrowingTaskAtom: AsyncAtom where Produced == Task<Success, any
///
/// - Throws: The error that occurred during the process of creating the resulting value.
///
/// - Returns: A throwing `Task` that produces asynchronous value.
/// - Returns: The process's result.
@MainActor
func value(context: Context) async throws -> Success
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Atoms/Atoms.docc/Atoms.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Building state by compositing atoms automatically optimizes rendering based on i
- ``StateAtom``
- ``TaskAtom``
- ``ThrowingTaskAtom``
- ``AsyncPhaseAtom``
- ``AsyncSequenceAtom``
- ``PublisherAtom``
- ``ObservableObjectAtom``
Expand Down
21 changes: 15 additions & 6 deletions Sources/Atoms/Core/Producer/AtomProducerContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,19 @@ internal struct AtomProducerContext<Value> {
return body(context)
}

func transaction<T>(_ body: @MainActor (AtomTransactionContext) async throws -> T) async rethrows -> T {
transactionState.begin()
let context = AtomTransactionContext(store: store, transactionState: transactionState)
defer { transactionState.commit() }
return try await body(context)
}
#if compiler(>=6)
func transaction<T, E: Error>(_ body: @MainActor (AtomTransactionContext) async throws(E) -> T) async throws(E) -> T {
transactionState.begin()
let context = AtomTransactionContext(store: store, transactionState: transactionState)
defer { transactionState.commit() }
return try await body(context)
}
#else
func transaction<T>(_ body: @MainActor (AtomTransactionContext) async throws -> T) async rethrows -> T {
transactionState.begin()
let context = AtomTransactionContext(store: store, transactionState: transactionState)
defer { transactionState.commit() }
return try await body(context)
}
#endif
}
Loading

0 comments on commit 77485a9

Please sign in to comment.