Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details><summary><code>📖 Example</code></summary>

Expand Down
29 changes: 18 additions & 11 deletions Sources/Atoms/AtomScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ public struct AtomScope<Content: View>: View {
}

private extension AtomScope {
@MainActor
final class State: ObservableObject {
let scopeState = ScopeState()
}

enum Inheritance {
case environment(scopeID: ScopeID)
case context(AtomViewContext)
Expand All @@ -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
Copy link
Preview

Copilot AI Jun 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The store property is missing its @Environment(\.store) property wrapper, so it will never be injected and will always be nil. It should be declared as @Environment(\.store) private var store.

Copilot uses AI. Check for mistakes.


@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)
Comment on lines +183 to +185
Copy link
Preview

Copilot AI Jun 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Registering a scope via side effects directly in the SwiftUI View body can lead to unpredictable behaviors. Consider moving registerScope to an onAppear modifier or the view initializer to avoid side effects during view recomputation.

Suggested change
scopedStore?.registerScope(state: state.scopeState)
return content.environment(\.store, scopedStore)
return content
.environment(\.store, scopedStore)
.onAppear {
scopedStore?.registerScope(state: state.scopeState)
}

Copilot uses AI. Check for mistakes.

}
}
}
6 changes: 5 additions & 1 deletion Sources/Atoms/Attribute/KeepAlive.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/// 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
///
Expand Down
11 changes: 8 additions & 3 deletions Sources/Atoms/Core/AtomCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,26 @@ 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
}

internal struct AtomCache<Node: Atom>: 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)
}
}
7 changes: 2 additions & 5 deletions Sources/Atoms/Core/AtomKey.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
internal struct AtomKey: Hashable, Sendable, CustomStringConvertible {
private let key: UnsafeUncheckedSendable<AnyHashable>
private let type: ObjectIdentifier
private let scopeKey: ScopeKey?
private let anyAtomType: Any.Type

let scopeKey: ScopeKey?

var description: String {
let atomLabel = String(describing: anyAtomType)

Expand All @@ -15,10 +16,6 @@ internal struct AtomKey: Hashable, Sendable, CustomStringConvertible {
}
}

var isScoped: Bool {
scopeKey != nil
}

init<Node: Atom>(_ atom: Node, scopeKey: ScopeKey?) {
self.key = UnsafeUncheckedSendable(atom.key)
self.type = ObjectIdentifier(Node.self)
Expand Down
1 change: 0 additions & 1 deletion Sources/Atoms/Core/Graph.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
internal struct Graph: Equatable {
var dependencies = [AtomKey: Set<AtomKey>]()
var children = [AtomKey: Set<AtomKey>]()
var subscribed = [SubscriberKey: Set<AtomKey>]()
}
6 changes: 2 additions & 4 deletions Sources/Atoms/Core/Scope.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
@MainActor
internal struct Scope {
let key: ScopeKey
let observers: [Observer]
let overrideContainer: OverrideContainer
let ancestorScopeKeys: [ScopeID: ScopeKey]
var atoms = Set<AtomKey>()
}
1 change: 1 addition & 0 deletions Sources/Atoms/Core/ScopeKey.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@usableFromInline
internal struct ScopeKey: Hashable, Sendable, CustomStringConvertible {
@MainActor
final class Token {
private(set) lazy var key = ScopeKey(token: self)
}
Expand Down
19 changes: 19 additions & 0 deletions Sources/Atoms/Core/ScopeState.swift
Original file line number Diff line number Diff line change
@@ -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?()
}
}
}
6 changes: 6 additions & 0 deletions Sources/Atoms/Core/ScopeValues.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
internal struct ScopeValues {
let key: ScopeKey
let observers: [Observer]
let overrideContainer: OverrideContainer
let ancestorScopeKeys: [ScopeID: ScopeKey]
}
Loading