Skip to content

Commit

Permalink
Custom Refreshable Attribute (#92)
Browse files Browse the repository at this point in the history
* Add ability to implement custom refresh

* Rename AtomUpdatedContext to AtomCurrentContext

* Update docc

* Rename dir name

* Add test cases

* Update README

* Add API documentation

* Fix test case
  • Loading branch information
ra1028 authored Dec 26, 2023
1 parent 4adcd5c commit 24bab62
Show file tree
Hide file tree
Showing 17 changed files with 430 additions and 30 deletions.
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,56 @@ struct FetchMasterDataAtom: ThrowingTaskAtom, KeepAlive, Hashable {

</details>

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

`Refreshable` allows you to implement a custom refreshable behavior to an atom.

<details><summary><code>📖 Expand to see example</code></summary>

It gives custom refresh behavior to ValueAtom which is inherently unable to refresh.

```swift
struct RandomIntAtom: ValueAtom, Refreshable, Hashable {
func value(context: Context) -> Int {
0
}

func refresh(context: RefreshContext) async -> Int {
try? await Task.sleep(nanoseconds: 3 * 1_000_000_000)
return .random(in: 0..<100)
}
}
```

It's also useful when you want to expose a converted value of an atom as another atom with having refresh ability while keeping the original one private visibility.
In this example, `FetchMoviesPhaseAtom` transparently exposes the value of `FetchMoviesTaskAtom` as AsyncPhase so that the error can be handled easily inside the atom, and `Refreshable` gives refreshing ability to `FetchMoviesPhaseAtom` itself.

```swift
private struct FetchMoviesTaskAtom: ThrowingTaskAtom, Hashable {
func value(context: Context) async throws -> [Movies] {
try await fetchMovies()
}
}

struct FetchMoviesPhaseAtom: ValueAtom, Refreshable, Hashable {
func value(context: Context) -> AsyncPhase<[Movies], Error> {
context.watch(FetchMoviesTaskAtom().phase)
}

func refresh(context: RefreshContext) async -> AsyncPhase<[Movies], Error> {
await context.refresh(FetchMoviesTaskAtom().phase)
}

func updated(newValue: AsyncPhase<[Movies], Error>, oldValue: AsyncPhase<[Movies], Error>, context: UpdatedContext) {
if case .failure = newValue {
print("Failed to fetch movies.")
}
}
}
```

</details>

---

### Property Wrappers
Expand Down
3 changes: 1 addition & 2 deletions Sources/Atoms/Atom/Atom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ public protocol Atom {
/// with other atoms.
typealias Context = AtomTransactionContext<Coordinator>

// NOTE: This typealias could not be compiled if defined as `AtomUpdatedContext<Coordinator>` at this point Swift version 5.7.2.
/// A type of the context structure that to read, set, and otherwise interacting
/// with other atoms.
typealias UpdatedContext = AtomUpdatedContext<Loader.Coordinator>
typealias UpdatedContext = AtomCurrentContext<Loader.Coordinator>

/// A unique value used to identify the atom internally.
///
Expand Down
3 changes: 2 additions & 1 deletion Sources/Atoms/Atoms.docc/Atoms.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Building state by compositing atoms automatically optimizes rendering based on i
### Attributes

- ``KeepAlive``
- ``Refreshable``

### Property Wrappers

Expand All @@ -60,7 +61,7 @@ Building state by compositing atoms automatically optimizes rendering based on i
- ``AtomTransactionContext``
- ``AtomViewContext``
- ``AtomTestContext``
- ``AtomUpdatedContext``
- ``AtomCurrentContext``
- ``AtomModifierContext``

### Internal System
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// A marker protocol that indicates that the value of atoms conform with this protocol
/// will continue to be retained even after they are no longer watched to.
/// An attribute protocol to allow the value of an atom to continue being retained
/// even after they are no longer watched to.
///
/// Note that this protocol doesn't apply to overridden atoms.
/// Note that overridden atoms are not retained even with this attribute.
///
/// ## Example
///
Expand Down
34 changes: 34 additions & 0 deletions Sources/Atoms/Attribute/Refreshable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/// An attribute protocol allows an atom to have a custom refresh ability.
///
/// Note that the custom refresh ability is not triggered when the atom is overridden.
///
/// ```swift
/// struct RandomIntAtom: ValueAtom, Refreshable, Hashable {
/// func value(context: Context) -> Int {
/// 0
/// }
///
/// func refresh(context: RefreshContext) async -> Int {
/// try? await Task.sleep(nanoseconds: 3 * 1_000_000_000)
/// return .random(in: 0..<100)
/// }
/// }
/// ```
///
public protocol Refreshable where Self: Atom {
/// A type of the context structure that to read, set, and otherwise interacting
/// with other atoms.
typealias RefreshContext = AtomCurrentContext<Loader.Coordinator>

/// Refreshes and then return a result value.
///
/// The value returned by this method will be cached as a new value when
/// this atom is refreshed.
///
/// - Parameter context: A context structure that to read, set, and otherwise interacting
/// with other atoms.
///
/// - Returns: A refreshed value.
@MainActor
func refresh(context: RefreshContext) async -> Loader.Value
}
4 changes: 4 additions & 0 deletions Sources/Atoms/Context/AtomContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,13 @@ public protocol AtomContext {
/// - Parameter atom: An atom that associates the value.
///
/// - Returns: The value which completed refreshing associated with the given atom.
@_disfavoredOverload
@discardableResult
func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader

@discardableResult
func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value

/// Resets the value associated with the given atom, and then notify.
///
/// This method resets a value for the given atom, and then notify update to the downstream
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// A context structure that to read, set, and otherwise interacting with atoms.
@MainActor
public struct AtomUpdatedContext<Coordinator>: AtomContext {
public struct AtomCurrentContext<Coordinator>: AtomContext {
@usableFromInline
internal let _store: StoreContext

Expand Down Expand Up @@ -97,12 +97,35 @@ public struct AtomUpdatedContext<Coordinator>: AtomContext {
/// - Parameter atom: An atom that associates the value.
///
/// - Returns: The value which completed refreshing associated with the given atom.
@discardableResult
@inlinable
@_disfavoredOverload
@discardableResult
public func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader {
await _store.refresh(atom)
}

/// Refreshes and then return the value associated with the given refreshable atom.
///
/// This method only accepts atoms that conform to ``Refreshable`` protocol.
/// It refreshes the value with the custom refresh behavior, so the caller can await until
/// the value completes the update.
/// Note that it can be used only in a context that supports concurrency.
///
/// ```swift
/// let context = ...
/// let value = await context.refresh(CustomRefreshableAtom())
/// print(value)
/// ```
///
/// - Parameter atom: An atom that associates the value.
///
/// - Returns: The value which completed refreshing associated with the given atom.
@inlinable
@discardableResult
public func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value {
await _store.refresh(atom)
}

/// Resets the value associated with the given atom, and then notify.
///
/// This method resets a value for the given atom, and then notify update to the downstream
Expand Down
23 changes: 23 additions & 0 deletions Sources/Atoms/Context/AtomTestContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,34 @@ public struct AtomTestContext: AtomWatchableContext {
///
/// - Returns: The value which completed refreshing associated with the given atom.
@inlinable
@_disfavoredOverload
@discardableResult
public func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader {
await _store.refresh(atom)
}

/// Refreshes and then return the value associated with the given refreshable atom.
///
/// This method only accepts atoms that conform to ``Refreshable`` protocol.
/// It refreshes the value with the custom refresh behavior, so the caller can await until
/// the value completes the update.
/// Note that it can be used only in a context that supports concurrency.
///
/// ```swift
/// let context = AtomTestContext()
/// let value = await context.refresh(CustomRefreshableAtom())
/// print(value)
/// ```
///
/// - Parameter atom: An atom that associates the value.
///
/// - Returns: The value which completed refreshing associated with the given atom.
@inlinable
@discardableResult
public func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value {
await _store.refresh(atom)
}

/// Resets the value associated with the given atom, and then notify.
///
/// This method resets a value for the given atom, and then notify update to the downstream
Expand Down
23 changes: 23 additions & 0 deletions Sources/Atoms/Context/AtomTransactionContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,34 @@ public struct AtomTransactionContext<Coordinator>: AtomWatchableContext {
///
/// - Returns: The value which completed refreshing associated with the given atom.
@inlinable
@_disfavoredOverload
@discardableResult
public func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader {
await _store.refresh(atom)
}

/// Refreshes and then return the value associated with the given refreshable atom.
///
/// This method only accepts atoms that conform to ``Refreshable`` protocol.
/// It refreshes the value with the custom refresh behavior, so the caller can await until
/// the value completes the update.
/// Note that it can be used only in a context that supports concurrency.
///
/// ```swift
/// let context = ...
/// let value = await context.refresh(CustomRefreshableAtom())
/// print(value)
/// ```
///
/// - Parameter atom: An atom that associates the value.
///
/// - Returns: The value which completed refreshing associated with the given atom.
@inlinable
@discardableResult
public func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value {
await _store.refresh(atom)
}

/// Resets the value associated with the given atom, and then notify.
///
/// This method resets a value for the given atom, and then notify update to the downstream
Expand Down
25 changes: 24 additions & 1 deletion Sources/Atoms/Context/AtomViewContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,35 @@ public struct AtomViewContext: AtomWatchableContext {
/// - Parameter atom: An atom that associates the value.
///
/// - Returns: The value which completed refreshing associated with the given atom.
@discardableResult
@inlinable
@_disfavoredOverload
@discardableResult
public func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader {
await _store.refresh(atom)
}

/// Refreshes and then return the value associated with the given refreshable atom.
///
/// This method only accepts atoms that conform to ``Refreshable`` protocol.
/// It refreshes the value with the custom refresh behavior, so the caller can await until
/// the value completes the update.
/// Note that it can be used only in a context that supports concurrency.
///
/// ```swift
/// let context = ...
/// let value = await context.refresh(CustomRefreshableAtom())
/// print(value)
/// ```
///
/// - Parameter atom: An atom that associates the value.
///
/// - Returns: The value which completed refreshing associated with the given atom.
@inlinable
@discardableResult
public func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value {
await _store.refresh(atom)
}

/// Resets the value associated with the given atom, and then notify.
///
/// This method resets a value for the given atom, and then notify update to the downstream
Expand Down
33 changes: 32 additions & 1 deletion Sources/Atoms/Core/StoreContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ internal struct StoreContext {
}

@usableFromInline
@_disfavoredOverload
func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader {
let override = lookupOverride(of: atom)
let key = AtomKey(atom, overrideScopeKey: override?.scopeKey)
Expand Down Expand Up @@ -169,6 +170,36 @@ internal struct StoreContext {
return value
}

@usableFromInline
func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value {
let override = lookupOverride(of: atom)
let key = AtomKey(atom, overrideScopeKey: override?.scopeKey)
let state = getState(of: atom, for: key)
let value: Node.Loader.Value

if let override {
value = override.value(atom)
}
else {
let context = AtomCurrentContext(store: self, coordinator: state.coordinator)
value = await atom.refresh(context: context)
}

guard let transaction = state.transaction, let cache = lookupCache(of: atom, for: key) else {
// Release the temporarily created state.
// Do not notify update to observers here because refresh doesn't create a new cache.
release(for: key)
return value
}

// Notify update unless it's cancelled or terminated by other operations.
if !Task.isCancelled && !transaction.isTerminated {
update(atom: atom, for: key, value: value, cache: cache, order: .newValue)
}

return value
}

@usableFromInline
func reset(_ atom: some Atom) {
let override = lookupOverride(of: atom)
Expand Down Expand Up @@ -372,7 +403,7 @@ private extension StoreContext {
notifyUpdateToObservers()

let state = getState(of: atom, for: key)
let context = AtomUpdatedContext(store: self, coordinator: state.coordinator)
let context = AtomCurrentContext(store: self, coordinator: state.coordinator)
atom.updated(newValue: value, oldValue: oldValue, context: context)
}

Expand Down
Loading

0 comments on commit 24bab62

Please sign in to comment.