Skip to content

Commit 24bab62

Browse files
authored
Custom Refreshable Attribute (#92)
* 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
1 parent 4adcd5c commit 24bab62

17 files changed

+430
-30
lines changed

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,56 @@ struct FetchMasterDataAtom: ThrowingTaskAtom, KeepAlive, Hashable {
757757

758758
</details>
759759

760+
#### [Refreshable](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/refreshable)
761+
762+
`Refreshable` allows you to implement a custom refreshable behavior to an atom.
763+
764+
<details><summary><code>📖 Expand to see example</code></summary>
765+
766+
It gives custom refresh behavior to ValueAtom which is inherently unable to refresh.
767+
768+
```swift
769+
struct RandomIntAtom: ValueAtom, Refreshable, Hashable {
770+
func value(context: Context) -> Int {
771+
0
772+
}
773+
774+
func refresh(context: RefreshContext) async -> Int {
775+
try? await Task.sleep(nanoseconds: 3 * 1_000_000_000)
776+
return .random(in: 0..<100)
777+
}
778+
}
779+
```
780+
781+
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.
782+
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.
783+
784+
```swift
785+
private struct FetchMoviesTaskAtom: ThrowingTaskAtom, Hashable {
786+
func value(context: Context) async throws -> [Movies] {
787+
try await fetchMovies()
788+
}
789+
}
790+
791+
struct FetchMoviesPhaseAtom: ValueAtom, Refreshable, Hashable {
792+
func value(context: Context) -> AsyncPhase<[Movies], Error> {
793+
context.watch(FetchMoviesTaskAtom().phase)
794+
}
795+
796+
func refresh(context: RefreshContext) async -> AsyncPhase<[Movies], Error> {
797+
await context.refresh(FetchMoviesTaskAtom().phase)
798+
}
799+
800+
func updated(newValue: AsyncPhase<[Movies], Error>, oldValue: AsyncPhase<[Movies], Error>, context: UpdatedContext) {
801+
if case .failure = newValue {
802+
print("Failed to fetch movies.")
803+
}
804+
}
805+
}
806+
```
807+
808+
</details>
809+
760810
---
761811

762812
### Property Wrappers

Sources/Atoms/Atom/Atom.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,9 @@ public protocol Atom {
1919
/// with other atoms.
2020
typealias Context = AtomTransactionContext<Coordinator>
2121

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

2726
/// A unique value used to identify the atom internally.
2827
///

Sources/Atoms/Atoms.docc/Atoms.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Building state by compositing atoms automatically optimizes rendering based on i
3434
### Attributes
3535

3636
- ``KeepAlive``
37+
- ``Refreshable``
3738

3839
### Property Wrappers
3940

@@ -60,7 +61,7 @@ Building state by compositing atoms automatically optimizes rendering based on i
6061
- ``AtomTransactionContext``
6162
- ``AtomViewContext``
6263
- ``AtomTestContext``
63-
- ``AtomUpdatedContext``
64+
- ``AtomCurrentContext``
6465
- ``AtomModifierContext``
6566

6667
### Internal System

Sources/Atoms/KeepAlive.swift renamed to Sources/Atoms/Attribute/KeepAlive.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
/// A marker protocol that indicates that the value of atoms conform with this protocol
2-
/// will continue to be retained even after they are no longer watched to.
1+
/// An attribute protocol to allow the value of an atom to continue being retained
2+
/// even after they are no longer watched to.
33
///
4-
/// Note that this protocol doesn't apply to overridden atoms.
4+
/// Note that overridden atoms are not retained even with this attribute.
55
///
66
/// ## Example
77
///
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/// An attribute protocol allows an atom to have a custom refresh ability.
2+
///
3+
/// Note that the custom refresh ability is not triggered when the atom is overridden.
4+
///
5+
/// ```swift
6+
/// struct RandomIntAtom: ValueAtom, Refreshable, Hashable {
7+
/// func value(context: Context) -> Int {
8+
/// 0
9+
/// }
10+
///
11+
/// func refresh(context: RefreshContext) async -> Int {
12+
/// try? await Task.sleep(nanoseconds: 3 * 1_000_000_000)
13+
/// return .random(in: 0..<100)
14+
/// }
15+
/// }
16+
/// ```
17+
///
18+
public protocol Refreshable where Self: Atom {
19+
/// A type of the context structure that to read, set, and otherwise interacting
20+
/// with other atoms.
21+
typealias RefreshContext = AtomCurrentContext<Loader.Coordinator>
22+
23+
/// Refreshes and then return a result value.
24+
///
25+
/// The value returned by this method will be cached as a new value when
26+
/// this atom is refreshed.
27+
///
28+
/// - Parameter context: A context structure that to read, set, and otherwise interacting
29+
/// with other atoms.
30+
///
31+
/// - Returns: A refreshed value.
32+
@MainActor
33+
func refresh(context: RefreshContext) async -> Loader.Value
34+
}

Sources/Atoms/Context/AtomContext.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,13 @@ public protocol AtomContext {
7979
/// - Parameter atom: An atom that associates the value.
8080
///
8181
/// - Returns: The value which completed refreshing associated with the given atom.
82+
@_disfavoredOverload
8283
@discardableResult
8384
func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader
8485

86+
@discardableResult
87+
func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value
88+
8589
/// Resets the value associated with the given atom, and then notify.
8690
///
8791
/// This method resets a value for the given atom, and then notify update to the downstream

Sources/Atoms/Context/AtomUpdatedContext.swift renamed to Sources/Atoms/Context/AtomCurrentContext.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// A context structure that to read, set, and otherwise interacting with atoms.
22
@MainActor
3-
public struct AtomUpdatedContext<Coordinator>: AtomContext {
3+
public struct AtomCurrentContext<Coordinator>: AtomContext {
44
@usableFromInline
55
internal let _store: StoreContext
66

@@ -97,12 +97,35 @@ public struct AtomUpdatedContext<Coordinator>: AtomContext {
9797
/// - Parameter atom: An atom that associates the value.
9898
///
9999
/// - Returns: The value which completed refreshing associated with the given atom.
100-
@discardableResult
101100
@inlinable
101+
@_disfavoredOverload
102+
@discardableResult
102103
public func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader {
103104
await _store.refresh(atom)
104105
}
105106

107+
/// Refreshes and then return the value associated with the given refreshable atom.
108+
///
109+
/// This method only accepts atoms that conform to ``Refreshable`` protocol.
110+
/// It refreshes the value with the custom refresh behavior, so the caller can await until
111+
/// the value completes the update.
112+
/// Note that it can be used only in a context that supports concurrency.
113+
///
114+
/// ```swift
115+
/// let context = ...
116+
/// let value = await context.refresh(CustomRefreshableAtom())
117+
/// print(value)
118+
/// ```
119+
///
120+
/// - Parameter atom: An atom that associates the value.
121+
///
122+
/// - Returns: The value which completed refreshing associated with the given atom.
123+
@inlinable
124+
@discardableResult
125+
public func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value {
126+
await _store.refresh(atom)
127+
}
128+
106129
/// Resets the value associated with the given atom, and then notify.
107130
///
108131
/// This method resets a value for the given atom, and then notify update to the downstream

Sources/Atoms/Context/AtomTestContext.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,34 @@ public struct AtomTestContext: AtomWatchableContext {
230230
///
231231
/// - Returns: The value which completed refreshing associated with the given atom.
232232
@inlinable
233+
@_disfavoredOverload
233234
@discardableResult
234235
public func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader {
235236
await _store.refresh(atom)
236237
}
237238

239+
/// Refreshes and then return the value associated with the given refreshable atom.
240+
///
241+
/// This method only accepts atoms that conform to ``Refreshable`` protocol.
242+
/// It refreshes the value with the custom refresh behavior, so the caller can await until
243+
/// the value completes the update.
244+
/// Note that it can be used only in a context that supports concurrency.
245+
///
246+
/// ```swift
247+
/// let context = AtomTestContext()
248+
/// let value = await context.refresh(CustomRefreshableAtom())
249+
/// print(value)
250+
/// ```
251+
///
252+
/// - Parameter atom: An atom that associates the value.
253+
///
254+
/// - Returns: The value which completed refreshing associated with the given atom.
255+
@inlinable
256+
@discardableResult
257+
public func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value {
258+
await _store.refresh(atom)
259+
}
260+
238261
/// Resets the value associated with the given atom, and then notify.
239262
///
240263
/// This method resets a value for the given atom, and then notify update to the downstream

Sources/Atoms/Context/AtomTransactionContext.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,34 @@ public struct AtomTransactionContext<Coordinator>: AtomWatchableContext {
106106
///
107107
/// - Returns: The value which completed refreshing associated with the given atom.
108108
@inlinable
109+
@_disfavoredOverload
109110
@discardableResult
110111
public func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader {
111112
await _store.refresh(atom)
112113
}
113114

115+
/// Refreshes and then return the value associated with the given refreshable atom.
116+
///
117+
/// This method only accepts atoms that conform to ``Refreshable`` protocol.
118+
/// It refreshes the value with the custom refresh behavior, so the caller can await until
119+
/// the value completes the update.
120+
/// Note that it can be used only in a context that supports concurrency.
121+
///
122+
/// ```swift
123+
/// let context = ...
124+
/// let value = await context.refresh(CustomRefreshableAtom())
125+
/// print(value)
126+
/// ```
127+
///
128+
/// - Parameter atom: An atom that associates the value.
129+
///
130+
/// - Returns: The value which completed refreshing associated with the given atom.
131+
@inlinable
132+
@discardableResult
133+
public func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value {
134+
await _store.refresh(atom)
135+
}
136+
114137
/// Resets the value associated with the given atom, and then notify.
115138
///
116139
/// This method resets a value for the given atom, and then notify update to the downstream

Sources/Atoms/Context/AtomViewContext.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,35 @@ public struct AtomViewContext: AtomWatchableContext {
104104
/// - Parameter atom: An atom that associates the value.
105105
///
106106
/// - Returns: The value which completed refreshing associated with the given atom.
107-
@discardableResult
108107
@inlinable
108+
@_disfavoredOverload
109+
@discardableResult
109110
public func refresh<Node: Atom>(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader {
110111
await _store.refresh(atom)
111112
}
112113

114+
/// Refreshes and then return the value associated with the given refreshable atom.
115+
///
116+
/// This method only accepts atoms that conform to ``Refreshable`` protocol.
117+
/// It refreshes the value with the custom refresh behavior, so the caller can await until
118+
/// the value completes the update.
119+
/// Note that it can be used only in a context that supports concurrency.
120+
///
121+
/// ```swift
122+
/// let context = ...
123+
/// let value = await context.refresh(CustomRefreshableAtom())
124+
/// print(value)
125+
/// ```
126+
///
127+
/// - Parameter atom: An atom that associates the value.
128+
///
129+
/// - Returns: The value which completed refreshing associated with the given atom.
130+
@inlinable
131+
@discardableResult
132+
public func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value {
133+
await _store.refresh(atom)
134+
}
135+
113136
/// Resets the value associated with the given atom, and then notify.
114137
///
115138
/// This method resets a value for the given atom, and then notify update to the downstream

Sources/Atoms/Core/StoreContext.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ internal struct StoreContext {
141141
}
142142

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

173+
@usableFromInline
174+
func refresh<Node: Refreshable>(_ atom: Node) async -> Node.Loader.Value {
175+
let override = lookupOverride(of: atom)
176+
let key = AtomKey(atom, overrideScopeKey: override?.scopeKey)
177+
let state = getState(of: atom, for: key)
178+
let value: Node.Loader.Value
179+
180+
if let override {
181+
value = override.value(atom)
182+
}
183+
else {
184+
let context = AtomCurrentContext(store: self, coordinator: state.coordinator)
185+
value = await atom.refresh(context: context)
186+
}
187+
188+
guard let transaction = state.transaction, let cache = lookupCache(of: atom, for: key) else {
189+
// Release the temporarily created state.
190+
// Do not notify update to observers here because refresh doesn't create a new cache.
191+
release(for: key)
192+
return value
193+
}
194+
195+
// Notify update unless it's cancelled or terminated by other operations.
196+
if !Task.isCancelled && !transaction.isTerminated {
197+
update(atom: atom, for: key, value: value, cache: cache, order: .newValue)
198+
}
199+
200+
return value
201+
}
202+
172203
@usableFromInline
173204
func reset(_ atom: some Atom) {
174205
let override = lookupOverride(of: atom)
@@ -372,7 +403,7 @@ private extension StoreContext {
372403
notifyUpdateToObservers()
373404

374405
let state = getState(of: atom, for: key)
375-
let context = AtomUpdatedContext(store: self, coordinator: state.coordinator)
406+
let context = AtomCurrentContext(store: self, coordinator: state.coordinator)
376407
atom.updated(newValue: value, oldValue: oldValue, context: context)
377408
}
378409

0 commit comments

Comments
 (0)