From ab8f598d9fa2eba3842f45a16f695ec1be118cf0 Mon Sep 17 00:00:00 2001 From: ra1028 Date: Thu, 29 May 2025 19:10:51 +0900 Subject: [PATCH 1/5] Keep an atom alive until its scope is unregistered if it conforms both KeepAlive and Scoped --- Sources/Atoms/AtomScope.swift | 29 +++-- Sources/Atoms/Core/AtomCache.swift | 11 +- Sources/Atoms/Core/AtomKey.swift | 7 +- Sources/Atoms/Core/Graph.swift | 1 - Sources/Atoms/Core/Scope.swift | 6 +- Sources/Atoms/Core/ScopeKey.swift | 1 + Sources/Atoms/Core/ScopeState.swift | 19 +++ Sources/Atoms/Core/ScopeValues.swift | 6 + Sources/Atoms/Core/StoreContext.swift | 113 ++++++++++++------ Sources/Atoms/Core/StoreState.swift | 2 + Tests/AtomsTests/Core/AtomCacheTests.swift | 6 +- Tests/AtomsTests/Core/ScopeKeyTests.swift | 1 + Tests/AtomsTests/Core/StoreContextTests.swift | 60 +++++----- Tests/AtomsTests/Utilities/Utilities.swift | 2 +- 14 files changed, 168 insertions(+), 96 deletions(-) create mode 100644 Sources/Atoms/Core/ScopeState.swift create mode 100644 Sources/Atoms/Core/ScopeValues.swift diff --git a/Sources/Atoms/AtomScope.swift b/Sources/Atoms/AtomScope.swift index 277480f6..1204c35b 100644 --- a/Sources/Atoms/AtomScope.swift +++ b/Sources/Atoms/AtomScope.swift @@ -150,6 +150,11 @@ public struct AtomScope: View { } private extension AtomScope { + @MainActor + final class State: ObservableObject { + let scopeState = ScopeState() + } + enum Inheritance { case environment(scopeID: ScopeID) case context(AtomViewContext) @@ -161,21 +166,23 @@ private extension AtomScope { let overrideContainer: OverrideContainer let content: Content - @State - private var scopeToken = ScopeKey.Token() @Environment(\.store) - private var environmentStore + private var store + + @StateObject + private var state = State() var body: some View { - content.environment( - \.store, - environmentStore?.scoped( - scopeID: scopeID, - scopeKey: scopeToken.key, - observers: observers, - overrideContainer: overrideContainer - ) + let scopedStore = store?.scoped( + scopeID: scopeID, + scopeKey: state.scopeState.token.key, + observers: observers, + overrideContainer: overrideContainer ) + + scopedStore?.registerScope(state: state.scopeState) + + return content.environment(\.store, scopedStore) } } } diff --git a/Sources/Atoms/Core/AtomCache.swift b/Sources/Atoms/Core/AtomCache.swift index 5f043f04..6d562a2e 100644 --- a/Sources/Atoms/Core/AtomCache.swift +++ b/Sources/Atoms/Core/AtomCache.swift @@ -3,7 +3,8 @@ internal protocol AtomCacheProtocol { var atom: Node { get } var value: Node.Produced { get } - var initializedScope: Scope? { get } + var scopeValues: ScopeValues? { get } + var shouldKeepAlive: Bool { get } func updated(value: Node.Produced) -> Self } @@ -11,13 +12,17 @@ internal protocol AtomCacheProtocol { internal struct AtomCache: AtomCacheProtocol, CustomStringConvertible { let atom: Node let value: Node.Produced - let initializedScope: Scope? + let scopeValues: ScopeValues? var description: String { "\(value)" } + var shouldKeepAlive: Bool { + atom is any KeepAlive + } + func updated(value: Node.Produced) -> Self { - AtomCache(atom: atom, value: value, initializedScope: initializedScope) + AtomCache(atom: atom, value: value, scopeValues: scopeValues) } } diff --git a/Sources/Atoms/Core/AtomKey.swift b/Sources/Atoms/Core/AtomKey.swift index 1e3ddf98..040c847a 100644 --- a/Sources/Atoms/Core/AtomKey.swift +++ b/Sources/Atoms/Core/AtomKey.swift @@ -1,9 +1,10 @@ internal struct AtomKey: Hashable, Sendable, CustomStringConvertible { private let key: UnsafeUncheckedSendable private let type: ObjectIdentifier - private let scopeKey: ScopeKey? private let anyAtomType: Any.Type + let scopeKey: ScopeKey? + var description: String { let atomLabel = String(describing: anyAtomType) @@ -15,10 +16,6 @@ internal struct AtomKey: Hashable, Sendable, CustomStringConvertible { } } - var isScoped: Bool { - scopeKey != nil - } - init(_ atom: Node, scopeKey: ScopeKey?) { self.key = UnsafeUncheckedSendable(atom.key) self.type = ObjectIdentifier(Node.self) diff --git a/Sources/Atoms/Core/Graph.swift b/Sources/Atoms/Core/Graph.swift index 2ad3d49a..62385dc5 100644 --- a/Sources/Atoms/Core/Graph.swift +++ b/Sources/Atoms/Core/Graph.swift @@ -1,5 +1,4 @@ internal struct Graph: Equatable { var dependencies = [AtomKey: Set]() var children = [AtomKey: Set]() - var subscribed = [SubscriberKey: Set]() } diff --git a/Sources/Atoms/Core/Scope.swift b/Sources/Atoms/Core/Scope.swift index 964f1f0f..572ce487 100644 --- a/Sources/Atoms/Core/Scope.swift +++ b/Sources/Atoms/Core/Scope.swift @@ -1,6 +1,4 @@ +@MainActor internal struct Scope { - let key: ScopeKey - let observers: [Observer] - let overrideContainer: OverrideContainer - let ancestorScopeKeys: [ScopeID: ScopeKey] + var atoms = Set() } diff --git a/Sources/Atoms/Core/ScopeKey.swift b/Sources/Atoms/Core/ScopeKey.swift index 117b59d0..ca8e007f 100644 --- a/Sources/Atoms/Core/ScopeKey.swift +++ b/Sources/Atoms/Core/ScopeKey.swift @@ -1,5 +1,6 @@ @usableFromInline internal struct ScopeKey: Hashable, Sendable, CustomStringConvertible { + @MainActor final class Token { private(set) lazy var key = ScopeKey(token: self) } diff --git a/Sources/Atoms/Core/ScopeState.swift b/Sources/Atoms/Core/ScopeState.swift new file mode 100644 index 00000000..41c567d7 --- /dev/null +++ b/Sources/Atoms/Core/ScopeState.swift @@ -0,0 +1,19 @@ +@usableFromInline +@MainActor +internal final class ScopeState { + let token = ScopeKey.Token() + + #if !hasFeature(IsolatedDefaultValues) + nonisolated init() {} + #endif + + nonisolated(unsafe) var unregister: (@MainActor () -> Void)? + + // TODO: Use isolated synchronous deinit once it's available. + // 0371-isolated-synchronous-deinit + deinit { + MainActor.performIsolated { [unregister] in + unregister?() + } + } +} diff --git a/Sources/Atoms/Core/ScopeValues.swift b/Sources/Atoms/Core/ScopeValues.swift new file mode 100644 index 00000000..4ce56095 --- /dev/null +++ b/Sources/Atoms/Core/ScopeValues.swift @@ -0,0 +1,6 @@ +internal struct ScopeValues { + let key: ScopeKey + let observers: [Observer] + let overrideContainer: OverrideContainer + let ancestorScopeKeys: [ScopeID: ScopeKey] +} diff --git a/Sources/Atoms/Core/StoreContext.swift b/Sources/Atoms/Core/StoreContext.swift index 5ff3e6c2..6de180cd 100644 --- a/Sources/Atoms/Core/StoreContext.swift +++ b/Sources/Atoms/Core/StoreContext.swift @@ -2,17 +2,17 @@ @MainActor internal struct StoreContext { private let store: AtomStore - private let rootScope: Scope - private let currentScope: Scope? + private let rootScopeValues: ScopeValues + private let currentScopeValues: ScopeValues? private init( store: AtomStore, - rootScope: Scope, - currentScope: Scope? = nil + rootScopeValues: ScopeValues, + currentScopeValues: ScopeValues? = nil ) { self.store = store - self.rootScope = rootScope - self.currentScope = currentScope + self.rootScopeValues = rootScopeValues + self.currentScopeValues = currentScopeValues } static func root( @@ -23,7 +23,7 @@ internal struct StoreContext { ) -> StoreContext { StoreContext( store: store, - rootScope: Scope( + rootScopeValues: ScopeValues( key: scopeKey, observers: observers, overrideContainer: overrideContainer, @@ -40,12 +40,12 @@ internal struct StoreContext { ) -> StoreContext { StoreContext( store: store, - rootScope: rootScope, - currentScope: Scope( + rootScopeValues: rootScopeValues, + currentScopeValues: ScopeValues( key: scopeKey, observers: observers, overrideContainer: overrideContainer, - ancestorScopeKeys: mutating(currentScope?.ancestorScopeKeys ?? [:]) { scopeKeys in + ancestorScopeKeys: mutating(currentScopeValues?.ancestorScopeKeys ?? [:]) { scopeKeys in scopeKeys[scopeID] = scopeKey } ) @@ -113,7 +113,7 @@ internal struct StoreContext { let (key, override) = lookupAtomKeyAndOverride(of: atom) let cache = lookupCache(of: atom, for: key) let value = cache?.value ?? initialize(of: atom, for: key, override: override) - let isNewSubscription = store.graph.subscribed[subscriber.key, default: []].insert(key).inserted + let isNewSubscription = store.state.subscribed[subscriber.key, default: []].insert(key).inserted if isNewSubscription { store.state.subscriptions[key, default: [:]][subscriber.key] = subscription @@ -224,10 +224,31 @@ internal struct StoreContext { func unwatch(_ atom: some Atom, subscriber: Subscriber) { let (key, _) = lookupAtomKeyAndOverride(of: atom) - store.graph.subscribed[subscriber.key]?.remove(key) + store.state.subscribed[subscriber.key]?.remove(key) unsubscribe([key], for: subscriber.key) } + @usableFromInline + func registerScope(state: ScopeState) { + let key = state.token.key + + withUnsafeMutablePointer(to: &store.state.scopes[key]) { scope in + if scope.pointee == nil { + scope.pointee = Scope() + } + } + + state.unregister = { + let scope = store.state.scopes.removeValue(forKey: key) + + if let scope { + for key in scope.atoms { + checkAndRelease(for: key) + } + } + } + } + @usableFromInline func snapshot() -> Snapshot { Snapshot( @@ -289,7 +310,11 @@ private extension StoreContext { state.effect.initializing(context: currentContext) let value = getValue(of: atom, for: key, override: override) - store.state.caches[key] = AtomCache(atom: atom, value: value, initializedScope: currentScope) + store.state.caches[key] = AtomCache(atom: atom, value: value, scopeValues: currentScopeValues) + + if let scopeKey = key.scopeKey { + store.state.scopes[scopeKey]?.atoms.insert(key) + } state.effect.initialized(context: currentContext) return value @@ -316,10 +341,10 @@ private extension StoreContext { // Calculate topological order for updating downstream efficiently. let (edges, redundantDependencies) = store.topologicalSorted(key: key) var skippedDependencies = Set() - var updatedScopes = [ScopeKey: Scope]() + var updatedScopes = [ScopeKey: ScopeValues]() - if let currentScope { - updatedScopes[currentScope.key] = currentScope + if let currentScopeValues { + updatedScopes[currentScopeValues.key] = currentScopeValues } func updatePropagation(for key: AtomKey, cache: some AtomCacheProtocol) { @@ -343,8 +368,8 @@ private extension StoreContext { let currentContext = AtomCurrentContext(store: localContext) state.effect.updated(context: currentContext) - if let scope = cache.initializedScope { - updatedScopes[scope.key] = scope + if let scopeValues = cache.scopeValues { + updatedScopes[scopeValues.key] = scopeValues } } @@ -402,7 +427,7 @@ private extension StoreContext { } // Notify the observers after all updates are completed. - notifyUpdateToObservers(scopes: updatedScopes.values) + notifyUpdateToObservers(scopeValues: updatedScopes.values) } func release(for key: AtomKey) { @@ -413,6 +438,10 @@ private extension StoreContext { store.graph.children.removeValue(forKey: key) store.state.subscriptions.removeValue(forKey: key) + if let scopeKey = key.scopeKey { + store.state.scopes[scopeKey]?.atoms.remove(key) + } + if let dependencies { for dependency in dependencies { store.graph.children[dependency]?.remove(key) @@ -432,15 +461,25 @@ private extension StoreContext { func checkAndRelease(for key: AtomKey) { // The condition under which an atom may be released are as follows: - // 1. It's not marked as `KeepAlive`, is marked as `Scoped`, or is scoped by override. + // 1. It's not marked as `KeepAlive`, or its scope is not alive. // 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 + lazy var shouldKeepAlive = { + guard let cache = store.state.caches[key], cache.shouldKeepAlive else { + return false + } + + guard let scopeKey = key.scopeKey else { + return true + } + + // It should keep alive untile the scope is unregistered. + return store.state.scopes[scopeKey]?.atoms.contains(key) ?? false + }() lazy var isChildrenEmpty = store.graph.children[key]?.isEmpty ?? true lazy var isSubscriptionEmpty = store.state.subscriptions[key]?.isEmpty ?? true - let shouldRelease = !shouldKeepAlive && isChildrenEmpty && isSubscriptionEmpty - guard shouldRelease else { + guard !shouldKeepAlive && isChildrenEmpty && isSubscriptionEmpty else { return } @@ -470,7 +509,7 @@ private extension StoreContext { } func unsubscribeAll(for subscriberKey: SubscriberKey) { - let keys = store.graph.subscribed.removeValue(forKey: subscriberKey) + let keys = store.state.subscribed.removeValue(forKey: subscriberKey) if let keys { unsubscribe(keys, for: subscriberKey) @@ -604,22 +643,22 @@ private extension StoreContext { } func lookupAtomKeyAndOverride(of atom: Node) -> (atomKey: AtomKey, override: Override?) { - func lookupOverride(for scope: Scope) -> Override? { - scope.overrideContainer.getOverride(for: atom) + func lookupOverride(for scopeValues: ScopeValues) -> Override? { + scopeValues.overrideContainer.getOverride(for: atom) } - if let currentScope, let override = lookupOverride(for: currentScope) { - let atomKey = AtomKey(atom, scopeKey: currentScope.key) + if let currentScopeValues, let override = lookupOverride(for: currentScopeValues) { + let atomKey = AtomKey(atom, scopeKey: currentScopeValues.key) return (atomKey: atomKey, override: override) } - else if let override = lookupOverride(for: rootScope) { + else if let override = lookupOverride(for: rootScopeValues) { // The scopeKey should be nil if it's overridden from the root. let atomKey = AtomKey(atom, scopeKey: nil) return (atomKey: atomKey, override: override) } else if let atom = atom as? any Scoped { let scopeID = ScopeID(atom.scopeID) - let scopeKey = currentScope?.ancestorScopeKeys[scopeID] + let scopeKey = currentScopeValues?.ancestorScopeKeys[scopeID] let atomKey = AtomKey(atom, scopeKey: scopeKey) return (atomKey: atomKey, override: nil) } @@ -630,13 +669,13 @@ private extension StoreContext { } func notifyUpdateToObservers() { - let scopes = currentScope.map { [$0] } ?? [] - notifyUpdateToObservers(scopes: scopes) + let scopeValues = currentScopeValues.map { [$0] } ?? [] + notifyUpdateToObservers(scopeValues: scopeValues) } - func notifyUpdateToObservers(scopes: some Sequence) { - let observers = rootScope.observers - let scopedObservers = scopes.flatMap(\.observers) + func notifyUpdateToObservers(scopeValues: some Sequence) { + let observers = rootScopeValues.observers + let scopedObservers = scopeValues.flatMap(\.observers) guard !observers.isEmpty || !scopedObservers.isEmpty else { return @@ -652,8 +691,8 @@ private extension StoreContext { func switchContext(with cache: some AtomCacheProtocol) -> StoreContext { StoreContext( store: store, - rootScope: rootScope, - currentScope: cache.initializedScope + rootScopeValues: rootScopeValues, + currentScopeValues: cache.scopeValues ) } } diff --git a/Sources/Atoms/Core/StoreState.swift b/Sources/Atoms/Core/StoreState.swift index 88b784a9..c67e5313 100644 --- a/Sources/Atoms/Core/StoreState.swift +++ b/Sources/Atoms/Core/StoreState.swift @@ -3,6 +3,8 @@ internal final class StoreState { var caches = [AtomKey: any AtomCacheProtocol]() var states = [AtomKey: any AtomStateProtocol]() var subscriptions = [AtomKey: [SubscriberKey: Subscription]]() + var subscribed = [SubscriberKey: Set]() + var scopes = [ScopeKey: Scope]() nonisolated init() {} } diff --git a/Tests/AtomsTests/Core/AtomCacheTests.swift b/Tests/AtomsTests/Core/AtomCacheTests.swift index 7d52c3c5..a08454dc 100644 --- a/Tests/AtomsTests/Core/AtomCacheTests.swift +++ b/Tests/AtomsTests/Core/AtomCacheTests.swift @@ -7,18 +7,18 @@ final class AtomCacheTests: XCTestCase { func testUpdated() { let atom = TestAtom(value: 0) let scopeToken = ScopeKey.Token() - let scope = Scope( + let scopeValues = ScopeValues( key: scopeToken.key, observers: [], overrideContainer: OverrideContainer(), ancestorScopeKeys: [:] ) - let cache = AtomCache(atom: atom, value: 0, initializedScope: scope) + let cache = AtomCache(atom: atom, value: 0, scopeValues: scopeValues) let updated = cache.updated(value: 1) XCTAssertEqual(updated.atom, atom) XCTAssertEqual(updated.value, 1) - XCTAssertEqual(updated.initializedScope?.key, scopeToken.key) + XCTAssertEqual(updated.scopeValues?.key, scopeToken.key) } @MainActor diff --git a/Tests/AtomsTests/Core/ScopeKeyTests.swift b/Tests/AtomsTests/Core/ScopeKeyTests.swift index 35a9633e..055b1797 100644 --- a/Tests/AtomsTests/Core/ScopeKeyTests.swift +++ b/Tests/AtomsTests/Core/ScopeKeyTests.swift @@ -3,6 +3,7 @@ import XCTest @testable import Atoms final class ScopeKeyTests: XCTestCase { + @MainActor func testDescription() { let token = ScopeKey.Token() let objectAddress = String(UInt(bitPattern: ObjectIdentifier(token)), radix: 16) diff --git a/Tests/AtomsTests/Core/StoreContextTests.swift b/Tests/AtomsTests/Core/StoreContextTests.swift index ed905e4a..6612ad8c 100644 --- a/Tests/AtomsTests/Core/StoreContextTests.swift +++ b/Tests/AtomsTests/Core/StoreContextTests.swift @@ -224,7 +224,7 @@ final class StoreContextTests: XCTestCase { ) XCTAssertEqual(initialValue, 0) - XCTAssertTrue(store.graph.subscribed[subscriber.key]?.contains(key) ?? false) + XCTAssertTrue(store.state.subscribed[subscriber.key]?.contains(key) ?? false) XCTAssertNotNil(store.state.subscriptions[key]?[subscriber.key]) XCTAssertEqual((store.state.caches[key] as? AtomCache)?.value, 0) XCTAssertEqual((store.state.caches[dependencyKey] as? AtomCache)?.value, 0) @@ -238,7 +238,7 @@ final class StoreContextTests: XCTestCase { subscriberState = nil XCTAssertEqual(updateCount, 1) - XCTAssertNil(store.graph.subscribed[subscriber.key]) + XCTAssertNil(store.state.subscribed[subscriber.key]) XCTAssertNil(store.state.caches[key]) XCTAssertNil(store.state.states[key]) XCTAssertNil(store.state.subscriptions[key]) @@ -398,32 +398,31 @@ final class StoreContextTests: XCTestCase { let subscriberState = SubscriberState() let subscriber = Subscriber(subscriberState) let atom = TestStateAtom(defaultValue: 0) - var snapshots = [Snapshot]() - let observer = Observer { snapshots.append($0) } let rootScopeToken = ScopeKey.Token() - let context = StoreContext.root( - store: store, - scopeKey: rootScopeToken.key, - observers: [observer], - overrideContainer: OverrideContainer() - ) + let context = StoreContext.root(store: store, scopeKey: rootScopeToken.key) _ = context.watch(atom, subscriber: subscriber, subscription: Subscription()) + + XCTAssertEqual( + store.state.caches.mapValues { $0.value as? Int }, + [AtomKey(atom): 0] + ) + + XCTAssertEqual( + store.state.subscribed, + [subscriber.key: [AtomKey(atom)]] + ) + context.unwatch(atom, subscriber: subscriber) XCTAssertEqual( - snapshots.map { $0.caches.mapValues { $0.value as? Int } }, - [ - [AtomKey(atom): 0], - [:], - ] + store.state.caches.mapValues { $0.value as? Int }, + [:] ) + XCTAssertEqual( - snapshots.map(\.graph), - [ - Graph(subscribed: [subscriber.key: [AtomKey(atom)]]), - Graph(subscribed: [subscriber.key: []]), - ] + store.state.subscribed, + [subscriber.key: []] ) } @@ -691,12 +690,6 @@ final class StoreContextTests: XCTestCase { AtomKey(atom), AtomKey(publisherAtom), ], - ], - subscribed: [ - subscriber.key: [ - AtomKey((atom)), - AtomKey((publisherAtom)), - ] ] ) ) @@ -737,6 +730,16 @@ final class StoreContextTests: XCTestCase { AtomKey(dependency2Atom, scopeKey: scope2Token.key): AtomCache(atom: dependency2Atom, value: 20), ] ) + + XCTAssertEqual( + store.state.subscribed, + [ + subscriber.key: [ + AtomKey((atom)), + AtomKey((publisherAtom)), + ] + ] + ) } @MainActor @@ -1067,11 +1070,6 @@ final class StoreContextTests: XCTestCase { AtomKey(TestDependency1Atom()): [AtomKey(TestAtom())], AtomKey(TestDependency2Atom()): [AtomKey(TestAtom())], AtomKey(TestDependency3Atom(), scopeKey: scopeToken.key): [AtomKey(TestAtom())], - ], - subscribed: [ - subscriber.key: [ - AtomKey(atom) - ] ] ) diff --git a/Tests/AtomsTests/Utilities/Utilities.swift b/Tests/AtomsTests/Utilities/Utilities.swift index 2096760b..ab9861b4 100644 --- a/Tests/AtomsTests/Utilities/Utilities.swift +++ b/Tests/AtomsTests/Utilities/Utilities.swift @@ -117,7 +117,7 @@ extension Atoms.Subscription { extension AtomCache { init(atom: Node, value: Node.Produced) { - self.init(atom: atom, value: value, initializedScope: nil) + self.init(atom: atom, value: value, scopeValues: nil) } } From 08642a81ad52a86a456f33ee100e4e1778aefd9f Mon Sep 17 00:00:00 2001 From: ra1028 Date: Tue, 3 Jun 2025 16:29:23 +0900 Subject: [PATCH 2/5] Refactoring --- Sources/Atoms/Core/StoreContext.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Atoms/Core/StoreContext.swift b/Sources/Atoms/Core/StoreContext.swift index 6de180cd..902c33eb 100644 --- a/Sources/Atoms/Core/StoreContext.swift +++ b/Sources/Atoms/Core/StoreContext.swift @@ -427,7 +427,7 @@ private extension StoreContext { } // Notify the observers after all updates are completed. - notifyUpdateToObservers(scopeValues: updatedScopes.values) + notifyUpdateToObservers(in: updatedScopes.values) } func release(for key: AtomKey) { @@ -643,15 +643,15 @@ private extension StoreContext { } func lookupAtomKeyAndOverride(of atom: Node) -> (atomKey: AtomKey, override: Override?) { - func lookupOverride(for scopeValues: ScopeValues) -> Override? { + func lookupOverride(in scopeValues: ScopeValues) -> Override? { scopeValues.overrideContainer.getOverride(for: atom) } - if let currentScopeValues, let override = lookupOverride(for: currentScopeValues) { + if let currentScopeValues, let override = lookupOverride(in: currentScopeValues) { let atomKey = AtomKey(atom, scopeKey: currentScopeValues.key) return (atomKey: atomKey, override: override) } - else if let override = lookupOverride(for: rootScopeValues) { + else if let override = lookupOverride(in: rootScopeValues) { // The scopeKey should be nil if it's overridden from the root. let atomKey = AtomKey(atom, scopeKey: nil) return (atomKey: atomKey, override: override) @@ -670,10 +670,10 @@ private extension StoreContext { func notifyUpdateToObservers() { let scopeValues = currentScopeValues.map { [$0] } ?? [] - notifyUpdateToObservers(scopeValues: scopeValues) + notifyUpdateToObservers(in: scopeValues) } - func notifyUpdateToObservers(scopeValues: some Sequence) { + func notifyUpdateToObservers(in scopeValues: some Sequence) { let observers = rootScopeValues.observers let scopedObservers = scopeValues.flatMap(\.observers) From ca2c407aa8fb13965a49be0befd084f13d7f9758 Mon Sep 17 00:00:00 2001 From: ra1028 Date: Tue, 3 Jun 2025 16:47:46 +0900 Subject: [PATCH 3/5] Add test case --- .../AtomsTests/Attribute/KeepAliveTests.swift | 70 ++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/Tests/AtomsTests/Attribute/KeepAliveTests.swift b/Tests/AtomsTests/Attribute/KeepAliveTests.swift index 30e74d15..85977bb4 100644 --- a/Tests/AtomsTests/Attribute/KeepAliveTests.swift +++ b/Tests/AtomsTests/Attribute/KeepAliveTests.swift @@ -5,7 +5,7 @@ import XCTest final class KeepAliveTests: XCTestCase { @MainActor func testKeepAliveAtoms() { - struct KeepAliveAtom: ValueAtom, KeepAlive, Hashable, @unchecked Sendable { + struct KeepAliveAtom: ValueAtom, KeepAlive, Hashable { let value: T func value(context: Context) -> T { @@ -13,7 +13,7 @@ final class KeepAliveTests: XCTestCase { } } - struct ScopedKeepAliveAtom: ValueAtom, KeepAlive, Scoped, Hashable, @unchecked Sendable { + struct ScopedKeepAliveAtom: ValueAtom, KeepAlive, Scoped, Hashable { let value: T func value(context: Context) -> T { @@ -37,18 +37,34 @@ final class KeepAliveTests: XCTestCase { XCTAssertNotNil(store.state.caches[key]) } - XCTContext.runActivity(named: "Should be released when overridden") { _ in + XCTContext.runActivity(named: "Should not be released when not scoped") { _ in + let store = AtomStore() + let rootScopeToken = ScopeKey.Token() + let context = StoreContext.root(store: store, scopeKey: rootScopeToken.key) + let atom = ScopedKeepAliveAtom(value: 0) + let key = AtomKey(atom, scopeKey: nil) + let subscriberState = SubscriberState() + let subscriber = Subscriber(subscriberState) + + _ = context.watch(atom, subscriber: subscriber, subscription: Subscription()) + XCTAssertNotNil(store.state.caches[key]) + + context.unwatch(atom, subscriber: subscriber) + XCTAssertNotNil(store.state.caches[key]) + } + + XCTContext.runActivity(named: "Should not be released until scope is released when overridden in scope") { _ in let store = AtomStore() let rootScopeToken = ScopeKey.Token() let context = StoreContext.root(store: store, scopeKey: rootScopeToken.key) let atom = KeepAliveAtom(value: 0) - let scopeToken = ScopeKey.Token() - let key = AtomKey(atom, scopeKey: scopeToken.key) + var scopeState: ScopeState! = ScopeState() + let key = AtomKey(atom, scopeKey: scopeState.token.key) let subscriberState = SubscriberState() let subscriber = Subscriber(subscriberState) let scopedContext = context.scoped( scopeID: ScopeID(DefaultScopeID()), - scopeKey: scopeToken.key, + scopeKey: scopeState.token.key, observers: [], overrideContainer: OverrideContainer() .addingOverride(for: atom) { _ in @@ -56,27 +72,61 @@ final class KeepAliveTests: XCTestCase { } ) + scopedContext.registerScope(state: scopeState) + + _ = scopedContext.watch(atom, subscriber: subscriber, subscription: Subscription()) + XCTAssertNotNil(store.state.caches[key]) + + scopedContext.unwatch(atom, subscriber: subscriber) + XCTAssertNotNil(store.state.caches[key]) + + scopeState = nil + XCTAssertNil(store.state.caches[key]) + } + + XCTContext.runActivity(named: "Should not be released until scope is released when scoped") { _ in + let store = AtomStore() + let rootScopeToken = ScopeKey.Token() + let context = StoreContext.root(store: store, scopeKey: rootScopeToken.key) + let atom = ScopedKeepAliveAtom(value: 0) + var scopeState: ScopeState! = ScopeState() + let key = AtomKey(atom, scopeKey: scopeState.token.key) + let scopedContext = context.scoped( + scopeID: ScopeID(DefaultScopeID()), + scopeKey: scopeState.token.key + ) + let subscriberState = SubscriberState() + let subscriber = Subscriber(subscriberState) + + scopedContext.registerScope(state: scopeState) + _ = scopedContext.watch(atom, subscriber: subscriber, subscription: Subscription()) XCTAssertNotNil(store.state.caches[key]) scopedContext.unwatch(atom, subscriber: subscriber) + XCTAssertNotNil(store.state.caches[key]) + + scopeState = nil XCTAssertNil(store.state.caches[key]) } - XCTContext.runActivity(named: "Should be released when scoped") { _ in + XCTContext.runActivity(named: "Should be released when scope is already released when scoped") { _ in let store = AtomStore() let rootScopeToken = ScopeKey.Token() let context = StoreContext.root(store: store, scopeKey: rootScopeToken.key) let atom = ScopedKeepAliveAtom(value: 0) - let scopeToken = ScopeKey.Token() - let key = AtomKey(atom, scopeKey: scopeToken.key) + var scopeState: ScopeState! = ScopeState() + let key = AtomKey(atom, scopeKey: scopeState.token.key) let scopedContext = context.scoped( scopeID: ScopeID(DefaultScopeID()), - scopeKey: scopeToken.key + scopeKey: scopeState.token.key ) let subscriberState = SubscriberState() let subscriber = Subscriber(subscriberState) + scopedContext.registerScope(state: scopeState) + scopeState = nil + _ = scopedContext.watch(atom, subscriber: subscriber, subscription: Subscription()) XCTAssertNotNil(store.state.caches[key]) From 797680b2e2bb45cee6c0ad7041c4c618bfc9bdda Mon Sep 17 00:00:00 2001 From: ra1028 Date: Tue, 3 Jun 2025 17:52:33 +0900 Subject: [PATCH 4/5] Update documentation --- README.md | 1 + Sources/Atoms/Attribute/KeepAlive.swift | 5 ++++- Sources/Atoms/Core/StoreContext.swift | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f4e67449..613a6b03 100644 --- a/README.md +++ b/README.md @@ -849,6 +849,7 @@ VStack { #### [KeepAlive](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/keepalive) `KeepAlive` allows the atom to preserve its data even if it's no longer watched from anywhere. +When used with the [Scoped](#scoped) attribute, the atom's data is preserved until the scope ([AtomScope](#atomscope)) it belongs to is dismantled from the view tree. This is useful when you want to prevent the atom from being released prematurely within a specific scope.
📖 Example diff --git a/Sources/Atoms/Attribute/KeepAlive.swift b/Sources/Atoms/Attribute/KeepAlive.swift index a52546f1..64fc5156 100644 --- a/Sources/Atoms/Attribute/KeepAlive.swift +++ b/Sources/Atoms/Attribute/KeepAlive.swift @@ -1,7 +1,10 @@ /// An attribute protocol to allow the value of an atom to continue being retained /// even after they are no longer watched. /// -/// Note that overridden or scoped atoms are not retained even with this attribute. +/// ## Note +/// +/// Atoms that conform to this attribute and are either scoped using the ``Scoped`` attribute +/// or overridden via ``AtomScope/scopedOverride(_:with:)-5jen3`` are retained until their scope is dismantled from the view tree, after which they are released. /// /// ## Example /// diff --git a/Sources/Atoms/Core/StoreContext.swift b/Sources/Atoms/Core/StoreContext.swift index 902c33eb..eb3e51cf 100644 --- a/Sources/Atoms/Core/StoreContext.swift +++ b/Sources/Atoms/Core/StoreContext.swift @@ -461,7 +461,7 @@ private extension StoreContext { func checkAndRelease(for key: AtomKey) { // The condition under which an atom may be released are as follows: - // 1. It's not marked as `KeepAlive`, or its scope is not alive. + // 1. It's not marked as `KeepAlive`, or its scope is already released. // 2. It has no downstream atoms. // 3. It has no subscriptions from views. lazy var shouldKeepAlive = { From df5cefadab8b8e6e77069db4fb2072cc1c19cf08 Mon Sep 17 00:00:00 2001 From: ra1028 Date: Tue, 3 Jun 2025 18:02:38 +0900 Subject: [PATCH 5/5] Refactoring --- Sources/Atoms/Attribute/KeepAlive.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Atoms/Attribute/KeepAlive.swift b/Sources/Atoms/Attribute/KeepAlive.swift index 64fc5156..defc4fc4 100644 --- a/Sources/Atoms/Attribute/KeepAlive.swift +++ b/Sources/Atoms/Attribute/KeepAlive.swift @@ -4,7 +4,8 @@ /// ## Note /// /// Atoms that conform to this attribute and are either scoped using the ``Scoped`` attribute -/// or overridden via ``AtomScope/scopedOverride(_:with:)-5jen3`` are retained until their scope is dismantled from the view tree, after which they are released. +/// or overridden via ``AtomScope/scopedOverride(_:with:)-5jen3`` are retained until their scope +/// is dismantled from the view tree, after which they are released. /// /// ## Example ///