diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/SearchAtoms.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/SearchAtoms.swift index bef854a9..65bf4d65 100644 --- a/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/SearchAtoms.swift +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/SearchAtoms.swift @@ -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()) diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/SearchScreen.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/SearchScreen.swift index 14d694b7..da32759b 100644 --- a/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/SearchScreen.swift +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/SearchScreen.swift @@ -2,7 +2,7 @@ import Atoms import SwiftUI struct SearchScreen: View { - @Watch(SearchMoviesAtom().phase) + @Watch(SearchMoviesAtom()) var movies @ViewContext @@ -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) diff --git a/Examples/Packages/iOS/Tests/ExampleMovieDBTests/ExampleMovieDBTests.swift b/Examples/Packages/iOS/Tests/ExampleMovieDBTests/ExampleMovieDBTests.swift index 6fb4467b..12a23e86 100644 --- a/Examples/Packages/iOS/Tests/ExampleMovieDBTests/ExampleMovieDBTests.swift +++ b/Examples/Packages/iOS/Tests/ExampleMovieDBTests/ExampleMovieDBTests.swift @@ -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) } } diff --git a/README.md b/README.md index 751862ff..1393a1cf 100644 --- a/README.md +++ b/README.md @@ -514,11 +514,63 @@ struct MoviesView: View { +#### [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` (`AsyncPhase` 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` | +|`throws(any Error)`|`throws` |`AsyncPhase`| +|`throws(Never)` | |`AsyncPhase` | + +
📖 Example + +```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) + } + } + } +} +``` + +
+ #### [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`| |Use Case |Handle multiple asynchronous values e.g. web-sockets| @@ -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`| |Use Case |Handle single or multiple asynchronous value(s) e.g. API call| diff --git a/Sources/Atoms/Atom/AsyncPhaseAtom.swift b/Sources/Atoms/Atom/AsyncPhaseAtom.swift new file mode 100644 index 00000000..885c2c96 --- /dev/null +++ b/Sources/Atoms/Atom/AsyncPhaseAtom.swift @@ -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`` +/// +/// ## 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 { + /// 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 { + 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 { + 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() + } + } + } +} diff --git a/Sources/Atoms/Atom/TaskAtom.swift b/Sources/Atoms/Atom/TaskAtom.swift index 8e48c023..05ee9301 100644 --- a/Sources/Atoms/Atom/TaskAtom.swift +++ b/Sources/Atoms/Atom/TaskAtom.swift @@ -46,7 +46,7 @@ public protocol TaskAtom: AsyncAtom where Produced == Task { /// - 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 } diff --git a/Sources/Atoms/Atom/ThrowingTaskAtom.swift b/Sources/Atoms/Atom/ThrowingTaskAtom.swift index 7404444c..85f10eb7 100644 --- a/Sources/Atoms/Atom/ThrowingTaskAtom.swift +++ b/Sources/Atoms/Atom/ThrowingTaskAtom.swift @@ -50,7 +50,7 @@ public protocol ThrowingTaskAtom: AsyncAtom where Produced == Task Success } diff --git a/Sources/Atoms/Atoms.docc/Atoms.md b/Sources/Atoms/Atoms.docc/Atoms.md index ccc2db72..e5f1264f 100644 --- a/Sources/Atoms/Atoms.docc/Atoms.md +++ b/Sources/Atoms/Atoms.docc/Atoms.md @@ -20,6 +20,7 @@ Building state by compositing atoms automatically optimizes rendering based on i - ``StateAtom`` - ``TaskAtom`` - ``ThrowingTaskAtom`` +- ``AsyncPhaseAtom`` - ``AsyncSequenceAtom`` - ``PublisherAtom`` - ``ObservableObjectAtom`` diff --git a/Sources/Atoms/Core/Producer/AtomProducerContext.swift b/Sources/Atoms/Core/Producer/AtomProducerContext.swift index 0be72730..ac4d8da0 100644 --- a/Sources/Atoms/Core/Producer/AtomProducerContext.swift +++ b/Sources/Atoms/Core/Producer/AtomProducerContext.swift @@ -34,10 +34,19 @@ internal struct AtomProducerContext { return body(context) } - func transaction(_ 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(_ 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(_ 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 } diff --git a/Tests/AtomsTests/Atom/AsyncPhaseAtomTests.swift b/Tests/AtomsTests/Atom/AsyncPhaseAtomTests.swift new file mode 100644 index 00000000..6d4f0ac5 --- /dev/null +++ b/Tests/AtomsTests/Atom/AsyncPhaseAtomTests.swift @@ -0,0 +1,153 @@ +import XCTest + +@testable import Atoms + +final class AsyncPhaseAtomTests: XCTestCase { + @MainActor + func test() async { + var result = Result.success(0) + let atom = TestAsyncPhaseAtom { result } + let context = AtomTestContext() + + do { + // Initial value + let phase = context.watch(atom) + XCTAssertTrue(phase.isSuspending) + } + + do { + // Value + await context.wait(for: atom, until: \.isSuccess) + let phase = context.watch(atom) + XCTAssertEqual(phase.value, 0) + } + + do { + // Failure + context.unwatch(atom) + result = .failure(URLError(.badURL)) + context.watch(atom) + await context.wait(for: atom, until: \.isFailure) + + let phase = context.watch(atom) + + #if compiler(>=6) + XCTAssertEqual(phase.error, URLError(.badURL)) + #else + XCTAssertEqual(phase.error as? URLError, URLError(.badURL)) + #endif + } + + do { + // Override + context.unwatch(atom) + context.override(atom) { _ in .success(200) } + + let phase = context.watch(atom) + XCTAssertEqual(phase.value, 200) + } + } + + @MainActor + func testRefresh() async { + let atom = TestAsyncPhaseAtom { .success(0) } + let context = AtomTestContext() + + do { + // Refresh + var updateCount = 0 + context.onUpdate = { updateCount += 1 } + context.watch(atom) + + let phase0 = await context.refresh(atom) + XCTAssertEqual(phase0.value, 0) + XCTAssertEqual(updateCount, 1) + } + + do { + // Cancellation + let refreshTask = Task { + await context.refresh(atom) + } + + Task { + refreshTask.cancel() + } + + let phase = await refreshTask.value + XCTAssertTrue(phase.isSuspending) + } + + do { + // Override + context.override(atom) { _ in .success(300) } + + let phase = await context.refresh(atom) + XCTAssertEqual(phase.value, 300) + } + } + + @MainActor + func testReleaseDependencies() async { + struct DependencyAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Int { + 0 + } + } + + struct TestAtom: AsyncPhaseAtom, Hashable { + func value(context: Context) async throws -> Int { + let dependency = context.watch(DependencyAtom()) + return dependency + } + } + + let atom = TestAtom() + let context = AtomTestContext() + + context.watch(atom) + await context.wait(for: atom, until: \.isSuccess) + + let phase0 = context.watch(atom) + XCTAssertEqual(phase0.value, 0) + + context[DependencyAtom()] = 100 + await context.wait(for: atom, until: \.isSuccess) + + let phase1 = context.watch(atom) + // Dependencies should not be released until task value is returned. + XCTAssertEqual(phase1.value, 100) + + context.unwatch(atom) + + let dependencyValue = context.read(DependencyAtom()) + XCTAssertEqual(dependencyValue, 0) + } + + @MainActor + func testEffect() { + let effect = TestEffect() + let atom = TestAsyncPhaseAtom(effect: effect) { .success(0) } + let context = AtomTestContext() + + context.watch(atom) + + XCTAssertEqual(effect.initializedCount, 1) + XCTAssertEqual(effect.updatedCount, 0) + XCTAssertEqual(effect.releasedCount, 0) + + context.reset(atom) + context.reset(atom) + context.reset(atom) + + XCTAssertEqual(effect.initializedCount, 1) + XCTAssertEqual(effect.updatedCount, 3) + XCTAssertEqual(effect.releasedCount, 0) + + context.unwatch(atom) + + XCTAssertEqual(effect.initializedCount, 1) + XCTAssertEqual(effect.updatedCount, 3) + XCTAssertEqual(effect.releasedCount, 1) + } +} diff --git a/Tests/AtomsTests/Utilities/TestAtom.swift b/Tests/AtomsTests/Utilities/TestAtom.swift index f8f6cf47..efde9a6b 100644 --- a/Tests/AtomsTests/Utilities/TestAtom.swift +++ b/Tests/AtomsTests/Utilities/TestAtom.swift @@ -78,6 +78,29 @@ struct TestThrowingTaskAtom: ThrowingTaskAtom, @unchecked Sen } } +struct TestAsyncPhaseAtom: AsyncPhaseAtom, @unchecked Sendable { + var effect: TestEffect? + var getResult: () -> Result + + var key: UniqueKey { + UniqueKey() + } + + #if compiler(>=6) + func value(context: Context) async throws(Failure) -> Success { + try getResult().get() + } + #else + func value(context: Context) async throws -> Success { + try getResult().get() + } + #endif + + func effect(context: CurrentContext) -> some AtomEffect { + effect ?? TestEffect() + } +} + struct TestCustomRefreshableAtom: ValueAtom, Refreshable, @unchecked Sendable { var getValue: (Context) -> T var refresh: (CurrentContext) async -> T