From ae553ae063cd169b9af36868d9fcf25888a866e1 Mon Sep 17 00:00:00 2001 From: ra1028 Date: Tue, 16 Apr 2024 22:52:46 +0900 Subject: [PATCH] Implement scoped atom feature --- Sources/Atoms/AtomRoot.swift | 14 +++----- Sources/Atoms/AtomScope.swift | 28 +++++++-------- Sources/Atoms/Atoms.docc/Atoms.md | 2 ++ Sources/Atoms/Attribute/KeepAlive.swift | 2 +- Sources/Atoms/Attribute/Scoped.swift | 15 ++++++++ Sources/Atoms/Context/AtomTestContext.swift | 1 + Sources/Atoms/Core/Environment.swift | 1 + Sources/Atoms/Core/ScopeID.swift | 7 ++++ Sources/Atoms/Core/StoreContext.swift | 35 ++++++++++++++----- .../{TaskExtensions.swift => Utilities.swift} | 6 ++++ Tests/AtomsTests/Core/StoreContextTests.swift | 10 ++++++ Tests/AtomsTests/Utilities/Util.swift | 1 + 12 files changed, 90 insertions(+), 32 deletions(-) create mode 100644 Sources/Atoms/Attribute/Scoped.swift create mode 100644 Sources/Atoms/Core/ScopeID.swift rename Sources/Atoms/Core/{TaskExtensions.swift => Utilities.swift} (62%) diff --git a/Sources/Atoms/AtomRoot.swift b/Sources/Atoms/AtomRoot.swift index 9894f486..e6e3d428 100644 --- a/Sources/Atoms/AtomRoot.swift +++ b/Sources/Atoms/AtomRoot.swift @@ -116,7 +116,7 @@ public struct AtomRoot: View { /// /// - Returns: The self instance. public func observe(_ onUpdate: @escaping @MainActor (Snapshot) -> Void) -> Self { - mutating { $0.observers.append(Observer(onUpdate: onUpdate)) } + mutating(self) { $0.observers.append(Observer(onUpdate: onUpdate)) } } /// Overrides the atom value with the given value. @@ -130,7 +130,7 @@ public struct AtomRoot: View { /// /// - Returns: The self instance. public func override(_ atom: Node, with value: @escaping (Node) -> Node.Loader.Value) -> Self { - mutating { $0.overrides[OverrideKey(atom)] = AtomOverride(value: value) } + mutating(self) { $0.overrides[OverrideKey(atom)] = AtomOverride(value: value) } } /// Overrides the atom value with the given value. @@ -146,7 +146,7 @@ public struct AtomRoot: View { /// /// - Returns: The self instance. public func override(_ atomType: Node.Type, with value: @escaping (Node) -> Node.Loader.Value) -> Self { - mutating { $0.overrides[OverrideKey(atomType)] = AtomOverride(value: value) } + mutating(self) { $0.overrides[OverrideKey(atomType)] = AtomOverride(value: value) } } } @@ -176,6 +176,7 @@ private extension AtomRoot { StoreContext( state.store, scopeKey: ScopeKey(token: state.token), + inheritedScopeKeys: [:], observers: observers, overrides: overrides ) @@ -203,16 +204,11 @@ private extension AtomRoot { StoreContext( store, scopeKey: ScopeKey(token: state.token), + inheritedScopeKeys: [:], observers: observers, overrides: overrides ) ) } } - - func `mutating`(_ mutation: (inout Self) -> Void) -> Self { - var view = self - mutation(&view) - return view - } } diff --git a/Sources/Atoms/AtomScope.swift b/Sources/Atoms/AtomScope.swift index aa3d1dc4..1f403cba 100644 --- a/Sources/Atoms/AtomScope.swift +++ b/Sources/Atoms/AtomScope.swift @@ -43,9 +43,12 @@ public struct AtomScope: View { /// Creates a new scope with the specified content. /// - /// - Parameter content: The view content that inheriting from the parent. - public init(@ViewBuilder content: () -> Content) { - self.inheritance = .environment + /// - Parameters: + /// - id: An identifier represents this scope used for matching scoped atoms. + /// - content: The view content that inheriting from the parent. + public init(id: ID = DefaultScopeID(), @ViewBuilder content: () -> Content) { + let id = ScopeID(id) + self.inheritance = .environment(id: id) self.content = content() } @@ -66,8 +69,9 @@ public struct AtomScope: View { /// The content and behavior of the view. public var body: some View { switch inheritance { - case .environment: + case .environment(let id): InheritedEnvironment( + id: id, content: content, overrides: overrides, observers: observers @@ -94,7 +98,7 @@ public struct AtomScope: View { /// /// - Returns: The self instance. public func observe(_ onUpdate: @escaping @MainActor (Snapshot) -> Void) -> Self { - mutating { $0.observers.append(Observer(onUpdate: onUpdate)) } + mutating(self) { $0.observers.append(Observer(onUpdate: onUpdate)) } } /// Override the atom value used in this scope with the given value. @@ -110,7 +114,7 @@ public struct AtomScope: View { /// /// - Returns: The self instance. public func override(_ atom: Node, with value: @escaping (Node) -> Node.Loader.Value) -> Self { - mutating { $0.overrides[OverrideKey(atom)] = AtomOverride(value: value) } + mutating(self) { $0.overrides[OverrideKey(atom)] = AtomOverride(value: value) } } /// Override the atom value used in this scope with the given value. @@ -128,13 +132,13 @@ public struct AtomScope: View { /// /// - Returns: The self instance. public func override(_ atomType: Node.Type, with value: @escaping (Node) -> Node.Loader.Value) -> Self { - mutating { $0.overrides[OverrideKey(atomType)] = AtomOverride(value: value) } + mutating(self) { $0.overrides[OverrideKey(atomType)] = AtomOverride(value: value) } } } private extension AtomScope { enum Inheritance { - case environment + case environment(id: ScopeID) case context(AtomViewContext) } @@ -144,6 +148,7 @@ private extension AtomScope { let token = ScopeKey.Token() } + let id: ScopeID let content: Content let overrides: [OverrideKey: any AtomOverrideProtocol] let observers: [Observer] @@ -158,6 +163,7 @@ private extension AtomScope { \.store, environmentStore.scoped( scopeKey: ScopeKey(token: state.token), + scopeID: id, observers: observers, overrides: overrides ) @@ -181,10 +187,4 @@ private extension AtomScope { ) } } - - func `mutating`(_ mutation: (inout Self) -> Void) -> Self { - var view = self - mutation(&view) - return view - } } diff --git a/Sources/Atoms/Atoms.docc/Atoms.md b/Sources/Atoms/Atoms.docc/Atoms.md index bb55fee4..1c483660 100644 --- a/Sources/Atoms/Atoms.docc/Atoms.md +++ b/Sources/Atoms/Atoms.docc/Atoms.md @@ -33,6 +33,7 @@ Building state by compositing atoms automatically optimizes rendering based on i ### Attributes +- ``Scoped`` - ``KeepAlive`` - ``Refreshable`` - ``Resettable`` @@ -85,3 +86,4 @@ Building state by compositing atoms automatically optimizes rendering based on i - ``ObservableObjectAtomLoader`` - ``ModifiedAtomLoader`` - ``AtomLoaderContext`` +- ``DefaultScopeID`` diff --git a/Sources/Atoms/Attribute/KeepAlive.swift b/Sources/Atoms/Attribute/KeepAlive.swift index a0a45bde..a52546f1 100644 --- a/Sources/Atoms/Attribute/KeepAlive.swift +++ b/Sources/Atoms/Attribute/KeepAlive.swift @@ -1,7 +1,7 @@ /// An attribute protocol to allow the value of an atom to continue being retained /// even after they are no longer watched. /// -/// Note that overridden atoms are not retained even with this attribute. +/// Note that overridden or scoped atoms are not retained even with this attribute. /// /// ## Example /// diff --git a/Sources/Atoms/Attribute/Scoped.swift b/Sources/Atoms/Attribute/Scoped.swift new file mode 100644 index 00000000..f4a7b9c9 --- /dev/null +++ b/Sources/Atoms/Attribute/Scoped.swift @@ -0,0 +1,15 @@ +public protocol Scoped where Self: Atom { + associatedtype ScopeID: Hashable = DefaultScopeID + + var scopeID: ScopeID { get } +} + +public extension Scoped where ScopeID == DefaultScopeID { + var scopeID: ScopeID { + DefaultScopeID() + } +} + +public struct DefaultScopeID: Hashable { + public init() {} +} diff --git a/Sources/Atoms/Context/AtomTestContext.swift b/Sources/Atoms/Context/AtomTestContext.swift index 92c98915..b9aea480 100644 --- a/Sources/Atoms/Context/AtomTestContext.swift +++ b/Sources/Atoms/Context/AtomTestContext.swift @@ -442,6 +442,7 @@ internal extension AtomTestContext { StoreContext( _state.store, scopeKey: ScopeKey(token: _state.token), + inheritedScopeKeys: [:], observers: [], overrides: _state.overrides ) diff --git a/Sources/Atoms/Core/Environment.swift b/Sources/Atoms/Core/Environment.swift index 5d5205ba..a431f6bd 100644 --- a/Sources/Atoms/Core/Environment.swift +++ b/Sources/Atoms/Core/Environment.swift @@ -12,6 +12,7 @@ private struct StoreEnvironmentKey: EnvironmentKey { StoreContext( nil, scopeKey: ScopeKey(token: ScopeKey.Token()), + inheritedScopeKeys: [:], observers: [], overrides: [:], enablesAssertion: true diff --git a/Sources/Atoms/Core/ScopeID.swift b/Sources/Atoms/Core/ScopeID.swift new file mode 100644 index 00000000..3333df9f --- /dev/null +++ b/Sources/Atoms/Core/ScopeID.swift @@ -0,0 +1,7 @@ +internal struct ScopeID: Hashable { + private let id: AnyHashable + + init(_ id: any Hashable) { + self.id = AnyHashable(id) + } +} diff --git a/Sources/Atoms/Core/StoreContext.swift b/Sources/Atoms/Core/StoreContext.swift index 003268b5..bea01dc9 100644 --- a/Sources/Atoms/Core/StoreContext.swift +++ b/Sources/Atoms/Core/StoreContext.swift @@ -5,6 +5,7 @@ import Foundation internal struct StoreContext { private weak var weakStore: AtomStore? private let scopeKey: ScopeKey + private let inheritedScopeKeys: [ScopeID: ScopeKey] private let observers: [Observer] private let overrides: [OverrideKey: any AtomOverrideProtocol] private let enablesAssertion: Bool @@ -12,12 +13,14 @@ internal struct StoreContext { nonisolated init( _ store: AtomStore?, scopeKey: ScopeKey, + inheritedScopeKeys: [ScopeID: ScopeKey], observers: [Observer], overrides: [OverrideKey: any AtomOverrideProtocol], enablesAssertion: Bool = false ) { self.weakStore = store self.scopeKey = scopeKey + self.inheritedScopeKeys = inheritedScopeKeys self.observers = observers self.overrides = overrides self.enablesAssertion = enablesAssertion @@ -30,6 +33,7 @@ internal struct StoreContext { StoreContext( weakStore, scopeKey: scopeKey, + inheritedScopeKeys: inheritedScopeKeys, observers: self.observers + observers, overrides: self.overrides.merging(overrides) { $1 }, enablesAssertion: enablesAssertion @@ -38,12 +42,14 @@ internal struct StoreContext { func scoped( scopeKey: ScopeKey, + scopeID: ScopeID, observers: [Observer], overrides: [OverrideKey: any AtomOverrideProtocol] ) -> StoreContext { StoreContext( weakStore, scopeKey: scopeKey, + inheritedScopeKeys: mutating(inheritedScopeKeys) { $0[scopeID] = scopeKey }, observers: self.observers + observers, overrides: overrides, enablesAssertion: enablesAssertion @@ -53,7 +59,7 @@ internal struct StoreContext { @usableFromInline func read(_ atom: Node) -> Node.Loader.Value { let override = lookupOverride(of: atom) - let scopeKey = override != nil ? scopeKey : nil + let scopeKey = lookupScopeKey(of: atom, isOverridden: override != nil) let key = AtomKey(atom, scopeKey: scopeKey) if let cache = lookupCache(of: atom, for: key) { @@ -74,7 +80,7 @@ internal struct StoreContext { @usableFromInline func set(_ value: Node.Loader.Value, for atom: Node) { let override = lookupOverride(of: atom) - let scopeKey = override != nil ? scopeKey : nil + let scopeKey = lookupScopeKey(of: atom, isOverridden: override != nil) let key = AtomKey(atom, scopeKey: scopeKey) if let cache = lookupCache(of: atom, for: key) { @@ -85,7 +91,7 @@ internal struct StoreContext { @usableFromInline func modify(_ atom: Node, body: (inout Node.Loader.Value) -> Void) { let override = lookupOverride(of: atom) - let scopeKey = override != nil ? scopeKey : nil + let scopeKey = lookupScopeKey(of: atom, isOverridden: override != nil) let key = AtomKey(atom, scopeKey: scopeKey) if let cache = lookupCache(of: atom, for: key) { @@ -103,7 +109,7 @@ internal struct StoreContext { let store = getStore() let override = lookupOverride(of: atom) - let scopeKey = override != nil ? scopeKey : nil + let scopeKey = lookupScopeKey(of: atom, isOverridden: override != nil) let key = AtomKey(atom, scopeKey: scopeKey) let newCache = lookupCache(of: atom, for: key) ?? makeNewCache(of: atom, for: key, override: override) @@ -123,7 +129,7 @@ internal struct StoreContext { ) -> Node.Loader.Value { let store = getStore() let override = lookupOverride(of: atom) - let scopeKey = override != nil ? scopeKey : nil + let scopeKey = lookupScopeKey(of: atom, isOverridden: override != nil) let key = AtomKey(atom, scopeKey: scopeKey) let newCache = lookupCache(of: atom, for: key) ?? makeNewCache(of: atom, for: key, override: override) let subscription = Subscription( @@ -149,7 +155,7 @@ internal struct StoreContext { @_disfavoredOverload func refresh(_ atom: Node) async -> Node.Loader.Value where Node.Loader: RefreshableAtomLoader { let override = lookupOverride(of: atom) - let scopeKey = override != nil ? scopeKey : nil + let scopeKey = lookupScopeKey(of: atom, isOverridden: override != nil) let key = AtomKey(atom, scopeKey: scopeKey) let context = prepareForTransaction(of: atom, for: key) let value: Node.Loader.Value @@ -179,7 +185,7 @@ internal struct StoreContext { @usableFromInline func refresh(_ atom: Node) async -> Node.Loader.Value { let override = lookupOverride(of: atom) - let scopeKey = override != nil ? scopeKey : nil + let scopeKey = lookupScopeKey(of: atom, isOverridden: override != nil) let key = AtomKey(atom, scopeKey: scopeKey) let state = getState(of: atom, for: key) let value: Node.Loader.Value @@ -445,7 +451,7 @@ private extension StoreContext { let store = getStore() // The condition under which an atom may be released are as follows: - // 1. It's not marked as `KeepAlive` or is overridden. + // 1. It's not marked as `KeepAlive`, is marked as `Scoped`, or is scoped by override. // 2. It has no downstream atoms. // 3. It has no subscriptions from views. lazy var shouldKeepAlive = !key.isScoped && store.state.caches[key].map { $0.atom is any KeepAlive } ?? false @@ -592,6 +598,19 @@ private extension StoreContext { return override } + func lookupScopeKey(of atom: Node, isOverridden: Bool) -> ScopeKey? { + if isOverridden { + return scopeKey + } + else if let atom = atom as? any Scoped { + let scopeID = ScopeID(atom.scopeID) + return inheritedScopeKeys[scopeID] + } + else { + return nil + } + } + func notifyUpdateToObservers() { guard !observers.isEmpty else { return diff --git a/Sources/Atoms/Core/TaskExtensions.swift b/Sources/Atoms/Core/Utilities.swift similarity index 62% rename from Sources/Atoms/Core/TaskExtensions.swift rename to Sources/Atoms/Core/Utilities.swift index ad697a97..104d1702 100644 --- a/Sources/Atoms/Core/TaskExtensions.swift +++ b/Sources/Atoms/Core/Utilities.swift @@ -1,3 +1,9 @@ +func `mutating`(_ value: T, _ mutation: (inout T) -> Void) -> T { + var value = value + mutation(&value) + return value +} + internal extension Task where Success == Never, Failure == Never { @inlinable static func sleep(seconds duration: Double) async throws { diff --git a/Tests/AtomsTests/Core/StoreContextTests.swift b/Tests/AtomsTests/Core/StoreContextTests.swift index 347696c9..33ee5a56 100644 --- a/Tests/AtomsTests/Core/StoreContextTests.swift +++ b/Tests/AtomsTests/Core/StoreContextTests.swift @@ -14,6 +14,7 @@ final class StoreContextTests: XCTestCase { let context = StoreContext( store, scopeKey: scopeKey, + inheritedScopeKeys: [:], observers: [], overrides: [ OverrideKey(atom): AtomOverride> { _ in @@ -53,6 +54,7 @@ final class StoreContextTests: XCTestCase { ) let scopedContext = context.scoped( scopeKey: ScopeKey(token: ScopeKey.Token()), + scopeID: ScopeID(DefaultScopeID()), observers: [observer1], overrides: [ OverrideKey(atom): AtomOverride> { _ in @@ -343,6 +345,7 @@ final class StoreContextTests: XCTestCase { let overrideAtomKey = AtomKey(atom, scopeKey: scopeKey) let scopedContext = context.scoped( scopeKey: scopeKey, + scopeID: ScopeID(DefaultScopeID()), observers: [], overrides: [ OverrideKey(atom): AtomOverride>> { _ in .success(1) } @@ -405,6 +408,7 @@ final class StoreContextTests: XCTestCase { let overrideAtomKey = AtomKey(atom, scopeKey: scopeKey) let scopedContext = context.scoped( scopeKey: scopeKey, + scopeID: ScopeID(DefaultScopeID()), observers: [], overrides: [ OverrideKey(atom): AtomOverride>> { _ in .success(2) } @@ -509,6 +513,7 @@ final class StoreContextTests: XCTestCase { let overrideAtomKey = AtomKey(atom, scopeKey: scopeKey) let scopedContext = context.scoped( scopeKey: scopeKey, + scopeID: ScopeID(DefaultScopeID()), observers: [], overrides: [ OverrideKey(atom): AtomOverride> { _ in 2 } @@ -645,11 +650,13 @@ final class StoreContextTests: XCTestCase { let context = StoreContext(store) let scoped1Context = context.scoped( scopeKey: scope1Key, + scopeID: ScopeID(DefaultScopeID()), observers: [], overrides: scoped1Overrides ) let scoped2Context = scoped1Context.scoped( scopeKey: scope2Key, + scopeID: ScopeID(DefaultScopeID()), observers: [], overrides: scoped2Overrides ) @@ -906,6 +913,7 @@ final class StoreContextTests: XCTestCase { let key = AtomKey(atom, scopeKey: scopeKey) let context = context.scoped( scopeKey: scopeKey, + scopeID: ScopeID(DefaultScopeID()), observers: [], overrides: [ OverrideKey(atom): AtomOverride> { _ in 10 } @@ -948,11 +956,13 @@ final class StoreContextTests: XCTestCase { ) let scoped1Context = context.scoped( scopeKey: scope1Key, + scopeID: ScopeID(DefaultScopeID()), observers: [], overrides: [:] ) let scoped2Context = scoped1Context.scoped( scopeKey: scope2Key, + scopeID: ScopeID(DefaultScopeID()), observers: [Observer { scopedSnapshots.append($0) }], overrides: scopedOverride ) diff --git a/Tests/AtomsTests/Utilities/Util.swift b/Tests/AtomsTests/Utilities/Util.swift index b4c965f5..93805169 100644 --- a/Tests/AtomsTests/Utilities/Util.swift +++ b/Tests/AtomsTests/Utilities/Util.swift @@ -66,6 +66,7 @@ extension StoreContext { self.init( store, scopeKey: ScopeKey(token: ScopeKey.Token()), + inheritedScopeKeys: [:], observers: observers, overrides: overrides )