├── .github ├── FUNDING.yml └── workflows │ ├── checks.yml │ ├── claude.yml │ └── claude-code-review.yml ├── Development ├── Development │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── App.swift │ ├── BookObservable.swift │ ├── UserDefaultsView.swift │ ├── ListView.swift │ └── ContentView.swift └── Development.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved │ └── xcshareddata │ └── xcschemes │ └── Development.xcscheme ├── .gitignore ├── Sources ├── StateGraphMacro │ ├── Plugin.swift │ ├── IgnoredMacro.swift │ ├── GraphViewMacro.swift │ ├── ComputedMacro.swift │ └── Extension.swift ├── StateGraph │ ├── Logger.swift │ ├── util.swift │ ├── Observation │ │ ├── Subscriptions.swift │ │ ├── withGraphTracking.swift │ │ ├── Filter.swift │ │ ├── withGraphTrackingGroup.swift │ │ ├── Node+Observe.swift │ │ ├── withTracking.swift │ │ └── withGraphTrackingMap.swift │ ├── Primitives │ │ ├── KeyPath.swift │ │ ├── Node.swift │ │ └── UserDefaultsStored.swift │ ├── SourceLocation.swift │ ├── ThreadLocal.swift │ ├── Macro.swift │ ├── NodeStore.swift │ ├── Documentation.docc │ │ ├── Documentation.md │ │ └── Quick-Start-Guide.md │ ├── SwiftUI.swift │ └── StorageAbstraction.swift └── StateGraphNormalization │ └── StateGraphNormalization.swift ├── .spi.yml ├── Tests ├── StateGraphTests │ ├── ModelInitializationTests.swift │ ├── TopLevelPropertyTests.swift │ ├── StaticPropertyTests.swift │ ├── Syntax.swift │ ├── ActorIsolationTests.swift │ ├── GraphTrackingCancellationTests.swift │ ├── NodeObserveTests.swift │ ├── DeadlockTests.swift │ ├── GraphTrackingsTests.swift │ ├── UserDefaultsStoredTests.swift │ ├── GraphTrackingMapTests.swift │ ├── ConcurrencyTests.swift │ └── KeyPathTests.swift └── StateGraphNormalizationTests │ └── NormalizationTests.swift ├── .swiftpm ├── swift-state-graph.xctestplan └── xcode │ └── xcshareddata │ └── xcschemes │ ├── StateGraphMacroTests.xcscheme │ ├── StateGraph.xcscheme │ ├── StateGraphNormalization.xcscheme │ ├── swift-state-graph.xcscheme │ └── swift-state-graph-Package.xcscheme ├── Package.resolved ├── Package.swift └── CLAUDE.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [muukii] 2 | patreon: muukii 3 | ko_fi: muukii 4 | -------------------------------------------------------------------------------- /Development/Development/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Development/Development/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Development/Development/App.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct DevelopmentApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Development/Development.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Development/Development/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | Derived 10 | *.xcodeproj 11 | settings.local.json 12 | -------------------------------------------------------------------------------- /Sources/StateGraphMacro/Plugin.swift: -------------------------------------------------------------------------------- 1 | import SwiftCompilerPlugin 2 | import SwiftSyntax 3 | import SwiftSyntaxBuilder 4 | import SwiftSyntaxMacros 5 | 6 | @main 7 | struct Plugin: CompilerPlugin { 8 | let providingMacros: [Macro.Type] = [ 9 | GraphViewMacro.self, 10 | ComputedMacro.self, 11 | IgnoredMacro.self, 12 | UnifiedStoredMacro.self, 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /Sources/StateGraph/Logger.swift: -------------------------------------------------------------------------------- 1 | import os.log 2 | 3 | enum Log { 4 | 5 | static let generic = Logger(OSLog.makeOSLogInDebug { OSLog.init(subsystem: "state-graph", category: "generic") }) 6 | 7 | } 8 | 9 | extension OSLog { 10 | 11 | @inline(__always) 12 | fileprivate static func makeOSLogInDebug(isEnabled: Bool = true, _ factory: () -> OSLog) -> OSLog { 13 | #if DEBUG 14 | return factory() 15 | #else 16 | return .disabled 17 | #endif 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | # This is manifest file for the Swift Package Index for it to auto-generate and 2 | # host DocC documentation. 3 | # 4 | # For reference see https://swiftpackageindex.com/swiftpackageindex/spimanifest/documentation/spimanifest/commonusecases#Host-DocC-documentation-in-the-Swift-Package-Index 5 | version: 1 6 | builder: 7 | configs: 8 | - documentation_targets: 9 | # First item in the list is the "landing" (default) target 10 | - StateGraph 11 | - StateGraphNormalization 12 | -------------------------------------------------------------------------------- /Sources/StateGraph/util.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | func withMainActor(_ closure: sending @escaping @MainActor () -> Void) { 4 | 5 | if #available(iOS 26, macOS 26, watchOS 26, tvOS 26, *) { 6 | Task.immediate { @MainActor in 7 | closure() 8 | } 9 | } else { 10 | if Thread.isMainThread { 11 | MainActor.assumeIsolated { 12 | closure() 13 | } 14 | } else { 15 | Task { @MainActor in 16 | closure() 17 | } 18 | } 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/ModelInitializationTests.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Testing 3 | import StateGraph 4 | 5 | @Suite 6 | struct ModelInitializationTests { 7 | 8 | @Test func basic() { 9 | 10 | final class StateViewModel { 11 | 12 | @GraphStored 13 | var optional_variable: Int? 14 | 15 | let shadow_value: Int 16 | 17 | init() { 18 | self.optional_variable = 0 19 | self.shadow_value = 0 20 | 21 | } 22 | 23 | } 24 | 25 | _ = StateViewModel() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/StateGraphMacro/IgnoredMacro.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IgnoredMacro.swift 3 | // swift-state-graph 4 | // 5 | // Created by Muukii on 2025/04/25. 6 | // 7 | 8 | import SwiftCompilerPlugin 9 | import SwiftSyntax 10 | import SwiftSyntaxBuilder 11 | import SwiftSyntaxMacros 12 | 13 | public struct IgnoredMacro: Macro { 14 | 15 | } 16 | 17 | extension IgnoredMacro: PeerMacro { 18 | public static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] { 19 | return [] 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /Sources/StateGraph/Observation/Subscriptions.swift: -------------------------------------------------------------------------------- 1 | 2 | // MARK: - Internals 3 | 4 | @_exported @preconcurrency import class Combine.AnyCancellable 5 | 6 | final class Subscriptions: Sendable, Hashable { 7 | 8 | static func == (lhs: Subscriptions, rhs: Subscriptions) -> Bool { 9 | lhs === rhs 10 | } 11 | 12 | func hash(into hasher: inout Hasher) { 13 | hasher.combine(ObjectIdentifier(self)) 14 | } 15 | 16 | let cancellables = OSAllocatedUnfairLock<[AnyCancellable]>(initialState: []) 17 | 18 | init() { 19 | 20 | } 21 | 22 | func append(_ cancellable: AnyCancellable) { 23 | cancellables.withLock { 24 | $0.append(cancellable) 25 | } 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Sources/StateGraph/Primitives/KeyPath.swift: -------------------------------------------------------------------------------- 1 | import Observation 2 | 3 | struct PointerKeyPathRoot: Observable, Sendable { 4 | 5 | static let shared = PointerKeyPathRoot() 6 | 7 | subscript(pointer pointer: UnsafeMutableRawPointer) -> Never { 8 | fatalError() 9 | } 10 | 11 | } 12 | 13 | @inline(__always) 14 | func _keyPath(_ object: AnyObject) -> any KeyPath & Sendable { 15 | let p = Unmanaged.passUnretained(object).toOpaque() 16 | let keyPath = \PointerKeyPathRoot[pointer: p] 17 | return keyPath 18 | } 19 | 20 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 21 | extension ObservationRegistrar { 22 | 23 | static let shared = ObservationRegistrar() 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Sources/StateGraph/SourceLocation.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct SourceLocation: Sendable { 3 | 4 | public let file: StaticString 5 | public let line: UInt 6 | public let column: UInt 7 | 8 | public init(file: StaticString, line: UInt, column: UInt) { 9 | self.file = file 10 | self.line = line 11 | self.column = column 12 | } 13 | 14 | public var text: String { 15 | "\(file):\(line):\(column)" 16 | } 17 | 18 | } 19 | 20 | public struct NodeInfo: Sendable { 21 | 22 | public let name: StaticString? 23 | public let sourceLocation: SourceLocation 24 | 25 | init( 26 | name: StaticString? = nil, 27 | sourceLocation: consuming SourceLocation 28 | ) { 29 | self.name = name 30 | self.sourceLocation = sourceLocation 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Development/Development/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Development/Development/BookObservable.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import SwiftUI 4 | 5 | private struct _Book: View { 6 | 7 | let model: ObservableModel = .init() 8 | 9 | var body: some View { 10 | Form { 11 | Button("Up Count1") { 12 | model.count1 += 1 13 | } 14 | Button("Up Count2") { 15 | model.count2 += 1 16 | } 17 | } 18 | .onAppear { 19 | 20 | withObservationTracking { 21 | _ = model.count1 22 | _ = model.count2 23 | } onChange: { 24 | print( 25 | "change" 26 | ) 27 | } 28 | 29 | } 30 | } 31 | } 32 | 33 | @Observable 34 | fileprivate class ObservableModel { 35 | 36 | var count1: Int = 0 37 | var count2: Int = 0 38 | } 39 | 40 | #Preview("BookObservable") { 41 | _Book() 42 | } 43 | -------------------------------------------------------------------------------- /Development/Development/UserDefaultsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import StateGraph 3 | 4 | 5 | final class UserDefaultsViewModel { 6 | 7 | @GraphStored var count: Int = 0 8 | 9 | @GraphStored(backed: .userDefaults(key: "A")) var savedCount: Int = 0 10 | 11 | } 12 | 13 | 14 | struct UserDefaultsView: View { 15 | 16 | let model: UserDefaultsViewModel = .init() 17 | 18 | var body: some View { 19 | let _ = Self._printChanges() 20 | Form { 21 | Text("Count: \(model.count)") 22 | Button("Increment") { 23 | model.count += 1 24 | } 25 | Text("Saved Count: \(model.savedCount)") 26 | Button("Save Count") { 27 | model.savedCount = model.count 28 | } 29 | Button("Update UserDefaults") { 30 | UserDefaults.standard.set(model.count, forKey: "A") 31 | } 32 | } 33 | 34 | } 35 | } 36 | 37 | #Preview("UserDefaults") { 38 | UserDefaultsView() 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: CommitChecks 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | jobs: 9 | package-test: 10 | runs-on: macos-26 11 | 12 | steps: 13 | - uses: maxim-lobanov/setup-xcode@v1.1 14 | with: 15 | xcode-version: "26.0" 16 | - uses: actions/checkout@v4 17 | - name: Run Test 18 | run: swift test 19 | 20 | build-development: 21 | runs-on: macos-26 22 | 23 | steps: 24 | - uses: maxim-lobanov/setup-xcode@v1.1 25 | with: 26 | xcode-version: "26.0" 27 | - uses: actions/checkout@v4 28 | - name: Build Development project 29 | run: | 30 | xcodebuild \ 31 | -skipMacroValidation \ 32 | -skipPackagePluginValidation \ 33 | -project Development/Development.xcodeproj \ 34 | -scheme Development \ 35 | -destination 'generic/platform=iOS Simulator' \ 36 | CODE_SIGNING_ALLOWED=NO build 37 | -------------------------------------------------------------------------------- /.swiftpm/swift-state-graph.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "675224CF-4A61-4D3C-BD32-31A1A1A36C88", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "mallocStackLoggingOptions" : { 13 | "loggingType" : "liveAllocations" 14 | } 15 | }, 16 | "testTargets" : [ 17 | { 18 | "target" : { 19 | "containerPath" : "container:", 20 | "identifier" : "StateGraphMacroTests", 21 | "name" : "StateGraphMacroTests" 22 | } 23 | }, 24 | { 25 | "target" : { 26 | "containerPath" : "container:", 27 | "identifier" : "StateGraphTests", 28 | "name" : "StateGraphTests" 29 | } 30 | }, 31 | { 32 | "target" : { 33 | "containerPath" : "container:", 34 | "identifier" : "StateGraphNormalizationTests", 35 | "name" : "StateGraphNormalizationTests" 36 | } 37 | } 38 | ], 39 | "version" : 1 40 | } 41 | -------------------------------------------------------------------------------- /Sources/StateGraph/ThreadLocal.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | struct ThreadLocalValue: ~Copyable, Sendable { 5 | 6 | var value: Value? { 7 | get { 8 | Thread.current.threadDictionary[key] as? Value 9 | } 10 | } 11 | 12 | let key: String 13 | 14 | init(key: String) { 15 | self.key = key 16 | } 17 | 18 | func withValue(_ value: Value?, perform: () throws -> R) rethrows -> R { 19 | let oldValue = Thread.current.threadDictionary[key] 20 | if let value { 21 | Thread.current.threadDictionary[key] = value 22 | } else { 23 | Thread.current.threadDictionary.removeObject(forKey: key) 24 | } 25 | defer { 26 | if let oldValue { 27 | Thread.current.threadDictionary[key] = oldValue 28 | } else { 29 | Thread.current.threadDictionary.removeObject(forKey: key) 30 | } 31 | } 32 | return try perform() 33 | } 34 | 35 | } 36 | 37 | enum ThreadLocal: Sendable { 38 | 39 | static let registration: ThreadLocalValue = .init(key: "org.vergegroup.state-graph.registration") 40 | static let subscriptions: ThreadLocalValue = .init(key: "org.vergegroup.state-graph.subscriptions") 41 | static let currentNode: ThreadLocalValue = .init(key: "org.vergegroup.state-graph.currentNode") 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/TopLevelPropertyTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import StateGraph 3 | 4 | // Top-level property test - this should not generate init accessors 5 | @GraphStored 6 | var topLevelValue1: Int = 42 7 | 8 | @GraphStored 9 | var topLevelValue2: Int = 42 10 | 11 | @Suite 12 | struct TopLevelPropertyTests { 13 | 14 | @Test func top_level_property_compilation() { 15 | // Test if top-level @GraphStored properties compile without init accessors 16 | print("Initial value: \(topLevelValue1)") 17 | 18 | topLevelValue1 = 100 19 | print("After setting to 100: \(topLevelValue1)") 20 | #expect(topLevelValue1 == 100) 21 | 22 | topLevelValue1 = 200 23 | print("After setting to 200: \(topLevelValue1)") 24 | #expect(topLevelValue1 == 200) 25 | } 26 | 27 | @Test func top_level_property_reactivity() async { 28 | // Reset value 29 | topLevelValue2 = 0 30 | 31 | await confirmation(expectedCount: 1) { c in 32 | let cancellable = withGraphTracking { 33 | withGraphTrackingMap { 34 | $topLevelValue2.wrappedValue 35 | } onChange: { value in 36 | if value == 999 { 37 | c.confirm() 38 | } 39 | } 40 | } 41 | 42 | try? await Task.sleep(for: .milliseconds(10)) 43 | 44 | topLevelValue2 = 999 45 | 46 | try? await Task.sleep(for: .milliseconds(10)) 47 | 48 | withExtendedLifetime(cancellable, {}) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/StateGraph/Primitives/Node.swift: -------------------------------------------------------------------------------- 1 | public protocol TypeErasedNode: Hashable, AnyObject, Sendable, CustomDebugStringConvertible { 2 | // var name: String? { get } 3 | var info: NodeInfo { get } 4 | var lock: NodeLock { get } 5 | 6 | /// edges affecting nodes 7 | var outgoingEdges: ContiguousArray { get set } 8 | 9 | /// inverse edges that depending on nodes 10 | var incomingEdges: ContiguousArray { get set } 11 | 12 | @_spi(Internal) 13 | var trackingRegistrations: Set { get set } 14 | 15 | var potentiallyDirty: Bool { get set } 16 | 17 | func recomputeIfNeeded() 18 | } 19 | 20 | public protocol Node: TypeErasedNode { 21 | 22 | associatedtype Value 23 | 24 | var wrappedValue: Value { get } 25 | 26 | } 27 | 28 | extension Node { 29 | // MARK: Equatable 30 | public static func == (lhs: Self, rhs: Self) -> Bool { 31 | return lhs === rhs 32 | } 33 | 34 | // MARK: Hashable 35 | public func hash(into hasher: inout Hasher) { 36 | hasher.combine(ObjectIdentifier(self)) 37 | } 38 | } 39 | 40 | extension Node { 41 | 42 | /** 43 | Create a computed value node that depends on this node. 44 | 45 | ```swift 46 | let computed = node.map { context, value in 47 | value * 2 48 | } 49 | ``` 50 | */ 51 | public func map( 52 | _ project: @escaping @Sendable (Computed.Context, Self.Value) -> ComputedValue 53 | ) -> Computed { 54 | return Computed { context in 55 | project(context, self.wrappedValue) 56 | } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /Sources/StateGraph/Macro.swift: -------------------------------------------------------------------------------- 1 | 2 | 3 | @attached(accessor, names: named(get), named(set)) 4 | @attached(peer, names: prefixed(`$`)) 5 | public macro GraphComputed() = #externalMacro(module: "StateGraphMacro", type: "ComputedMacro") 6 | 7 | 8 | 9 | @attached(peer) 10 | public macro GraphIgnored() = #externalMacro(module: "StateGraphMacro", type: "IgnoredMacro") 11 | 12 | // MARK: - Backing Storage Types 13 | 14 | /// Represents different types of backing storage for GraphStored properties 15 | public enum GraphStorageBacking { 16 | /// In-memory storage (default) 17 | case memory 18 | /// UserDefaults storage with a key 19 | case userDefaults(key: String) 20 | /// UserDefaults storage with suite and key 21 | case userDefaults(suite: String, key: String) 22 | /// UserDefaults storage with suite, key, and name 23 | case userDefaults(suite: String, key: String, name: String) 24 | } 25 | 26 | // MARK: - Unified GraphStored Macro 27 | 28 | /// Unified macro that supports different backing storage types 29 | /// 30 | /// Usage: 31 | /// ```swift 32 | /// @GraphStored var count: Int = 0 // Memory storage (default) 33 | /// @GraphStored(backed: .userDefaults(key: "count")) var storedCount: Int = 0 34 | /// @GraphStored(backed: .userDefaults(suite: "com.app", key: "theme")) var theme: String = "light" 35 | /// ``` 36 | @attached(accessor, names: named(init), named(get), named(set)) 37 | @attached(peer, names: prefixed(`$`)) 38 | public macro GraphStored(backed: GraphStorageBacking = .memory) = #externalMacro(module: "StateGraphMacro", type: "UnifiedStoredMacro") 39 | 40 | @_exported import os.lock 41 | -------------------------------------------------------------------------------- /Sources/StateGraph/NodeStore.swift: -------------------------------------------------------------------------------- 1 | /// for debugging 2 | public actor NodeStore { 3 | 4 | public static let shared = NodeStore() 5 | 6 | private var nodes: ContiguousArray = [] 7 | private var isEnabled: Bool = false 8 | 9 | public func enable() { 10 | isEnabled = true 11 | } 12 | 13 | public func disable() { 14 | isEnabled = false 15 | nodes.removeAll() 16 | } 17 | 18 | func register(node: any TypeErasedNode) { 19 | guard isEnabled else { return } 20 | nodes.append(.init(node)) 21 | compact() 22 | } 23 | 24 | private func compact() { 25 | nodes.removeAll { 26 | $0.value == nil 27 | } 28 | } 29 | 30 | public var _nodes: [any TypeErasedNode] { 31 | nodes.compactMap { $0.value } 32 | } 33 | 34 | public func graphViz() -> String { 35 | func name(_ node: any TypeErasedNode) -> String { 36 | return #""\#(node.info.name.map(String.init) ?? "noname")_\#(node.info.sourceLocation.text)""# 37 | } 38 | 39 | let allNodes = nodes.compactMap { $0.value } 40 | let nodesStr = allNodes.map { name($0) }.joined(separator: "\n") 41 | 42 | // edges 43 | let edges = allNodes 44 | .flatMap(\.outgoingEdges).map { 45 | "\(name($0.from)) -> \(name($0.to))\($0.isPending ? " [style=dashed]" : "")" 46 | }.joined(separator: "\n") 47 | 48 | return """ 49 | digraph StateGraph { 50 | 51 | \(nodesStr) 52 | 53 | \(edges) 54 | } 55 | """ 56 | } 57 | 58 | } 59 | 60 | private struct WeakNode: Equatable { 61 | 62 | static func == (lhs: WeakNode, rhs: WeakNode) -> Bool { 63 | return lhs.value === rhs.value 64 | } 65 | 66 | weak var value: (any TypeErasedNode)? 67 | 68 | init(_ value: any TypeErasedNode) { 69 | self.value = value 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/StateGraphNormalization/StateGraphNormalization.swift: -------------------------------------------------------------------------------- 1 | @_exported import StateGraph 2 | @_exported import TypedIdentifier 3 | 4 | import TypedIdentifier 5 | 6 | public struct EntityStore: Sendable { 7 | private var entities: [T.TypedID : T] 8 | 9 | public init(entities: [T.TypedID: T] = [:]) { 10 | self.entities = entities 11 | } 12 | 13 | public func get(by id: T.TypedID) -> T? { 14 | return entities[id] 15 | } 16 | 17 | public func getAll() -> some Collection { 18 | return entities.values 19 | } 20 | 21 | public mutating func add(_ entity: T) { 22 | entities[entity.id] = entity 23 | } 24 | 25 | public mutating func add(_ entities: some Sequence) { 26 | for entity in entities { 27 | self.add(entity) 28 | } 29 | } 30 | 31 | public mutating func modify(_ id: T.TypedID, _ block: (inout T) -> Void) { 32 | if var entity = entities[id] { 33 | block(&entity) 34 | entities[id] = entity 35 | } 36 | } 37 | 38 | public func filter(_ predicate: (T) -> Bool) -> [T] { 39 | return entities.values.filter(predicate) 40 | } 41 | 42 | public mutating func update(_ entity: T) { 43 | entities[entity.id] = entity 44 | } 45 | 46 | public mutating func delete(_ id: T.TypedID) { 47 | entities.removeValue(forKey: id) 48 | } 49 | 50 | public var isEmpty: Bool { 51 | return entities.isEmpty 52 | } 53 | 54 | public var count: Int { 55 | return entities.count 56 | } 57 | 58 | public func contains(_ id: T.TypedID) -> Bool { 59 | return entities[id] != nil 60 | } 61 | 62 | public subscript(_ id: T.TypedID) -> T? { 63 | get { 64 | return entities[id] 65 | } 66 | set { 67 | if let newValue = newValue { 68 | entities[id] = newValue 69 | } else { 70 | entities.removeValue(forKey: id) 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "322cadd5b2f5998d555d75617fa573ea8ece24cc4ba34981336ffac078466172", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-custom-dump", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 8 | "state" : { 9 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 10 | "version" : "1.3.3" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-macro-testing", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/pointfreeco/swift-macro-testing.git", 17 | "state" : { 18 | "revision" : "9ab11325daa51c7c5c10fcf16c92bac906717c7e", 19 | "version" : "0.6.4" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-snapshot-testing", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing", 26 | "state" : { 27 | "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", 28 | "version" : "1.18.7" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-syntax", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/swiftlang/swift-syntax.git", 35 | "state" : { 36 | "revision" : "4799286537280063c85a32f09884cfbca301b1a1", 37 | "version" : "602.0.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-typed-identifier", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/VergeGroup/swift-typed-identifier.git", 44 | "state" : { 45 | "revision" : "ea7afa2ce943c6bf3ef87d385c8a6e9f67fea4f3", 46 | "version" : "2.0.4" 47 | } 48 | }, 49 | { 50 | "identity" : "xctest-dynamic-overlay", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 53 | "state" : { 54 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 55 | "version" : "1.5.2" 56 | } 57 | } 58 | ], 59 | "version" : 3 60 | } 61 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/StateGraphMacroTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 19 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 46 | 47 | 49 | 50 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Sources/StateGraph/Observation/withGraphTracking.swift: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | Creates a tracking scope for observing node changes in the StateGraph. 4 | 5 | This function establishes a reactive tracking context where you can use `withGraphTrackingMap`, 6 | `withGraphTrackingGroup`, or other tracking functions. All subscriptions created within this 7 | scope are automatically managed and cleaned up when the returned cancellable is cancelled or 8 | deallocated. 9 | 10 | ## Basic Usage 11 | ```swift 12 | let node = Stored(wrappedValue: 0) 13 | 14 | let cancellable = withGraphTracking { 15 | withGraphTrackingMap { 16 | node.wrappedValue 17 | } onChange: { value in 18 | print("Value changed to: \(value)") 19 | } 20 | } 21 | 22 | // Later: cancel all subscriptions 23 | cancellable.cancel() 24 | ``` 25 | 26 | ## Multiple Subscriptions 27 | ```swift 28 | let cancellable = withGraphTracking { 29 | withGraphTrackingMap { node1.wrappedValue } onChange: { print("Node1: \($0)") } 30 | withGraphTrackingMap { node2.wrappedValue } onChange: { print("Node2: \($0)") } 31 | withGraphTrackingMap { node3.wrappedValue } onChange: { print("Node3: \($0)") } 32 | } 33 | // All subscriptions are managed together 34 | ``` 35 | 36 | ## Group Tracking 37 | ```swift 38 | let cancellable = withGraphTracking { 39 | withGraphTrackingGroup { 40 | if condition.wrappedValue { 41 | performOperation(conditionalNode.wrappedValue) 42 | } 43 | updateUI(alwaysNode.wrappedValue) 44 | } 45 | } 46 | ``` 47 | 48 | ## Memory Management 49 | - The returned `AnyCancellable` manages all subscriptions created within the scope 50 | - Subscriptions are automatically cancelled when the cancellable is deallocated 51 | - Use `cancellable.cancel()` for explicit cleanup 52 | 53 | - Parameter scope: A closure where you set up your node observations 54 | - Returns: An `AnyCancellable` that manages all subscriptions created within the scope 55 | */ 56 | public func withGraphTracking(_ scope: () -> Void) -> AnyCancellable { 57 | 58 | let subscriptions = ThreadLocal.subscriptions.withValue(.init()) { 59 | scope() 60 | 61 | return ThreadLocal.subscriptions.value! 62 | } 63 | 64 | return AnyCancellable { 65 | withExtendedLifetime(subscriptions) {} 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import CompilerPluginSupport 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "swift-state-graph", 9 | platforms: [ 10 | .macOS( 11 | .v14 12 | ), 13 | .iOS(.v17), 14 | .tvOS(.v17), 15 | .watchOS(.v10), 16 | ], 17 | products: [ 18 | // Products define the executables and libraries a package produces, making them visible to other packages. 19 | .library( 20 | name: "StateGraph", 21 | targets: ["StateGraph"] 22 | ), 23 | .library( 24 | name: "StateGraphNormalization", 25 | targets: ["StateGraphNormalization"] 26 | ) 27 | ], 28 | dependencies: [ 29 | .package(url: "https://github.com/VergeGroup/swift-typed-identifier.git", from: "2.0.4"), 30 | .package(url: "https://github.com/swiftlang/swift-syntax.git", "600.0.0"..<"603.0.0"), 31 | .package(url: "https://github.com/pointfreeco/swift-macro-testing.git", from: "0.5.2"), 32 | ], 33 | targets: [ 34 | .macro( 35 | name: "StateGraphMacro", 36 | dependencies: [ 37 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 38 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), 39 | ] 40 | ), 41 | .target( 42 | name: "StateGraph", 43 | dependencies: [ 44 | "StateGraphMacro" 45 | ] 46 | ), 47 | .target( 48 | name: "StateGraphNormalization", 49 | dependencies: [ 50 | "StateGraph", 51 | .product(name: "TypedIdentifier", package: "swift-typed-identifier") 52 | ] 53 | ), 54 | .testTarget( 55 | name: "StateGraphMacroTests", 56 | dependencies: [ 57 | "StateGraphMacro", 58 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 59 | .product(name: "MacroTesting", package: "swift-macro-testing"), 60 | ] 61 | ), 62 | .testTarget( 63 | name: "StateGraphTests", 64 | dependencies: ["StateGraph"] 65 | ), 66 | .testTarget( 67 | name: "StateGraphNormalizationTests", 68 | dependencies: [ 69 | "StateGraph", 70 | "StateGraphNormalization" 71 | ] 72 | ), 73 | ], 74 | swiftLanguageModes: [.v6] 75 | ) 76 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: macos-15 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@beta 36 | with: 37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | 39 | # This is an optional setting that allows Claude to read CI results on PRs 40 | additional_permissions: | 41 | actions: read 42 | 43 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 44 | # model: "claude-opus-4-20250514" 45 | 46 | # Optional: Customize the trigger phrase (default: @claude) 47 | # trigger_phrase: "/claude" 48 | 49 | # Optional: Trigger when specific user is assigned to an issue 50 | # assignee_trigger: "claude-bot" 51 | 52 | # Optional: Allow Claude to run specific commands 53 | # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" 54 | 55 | # Optional: Add custom instructions for Claude to customize its behavior for your project 56 | # custom_instructions: | 57 | # Follow our coding standards 58 | # Ensure all new code has tests 59 | # Use TypeScript for new files 60 | 61 | # Optional: Custom environment variables for Claude 62 | # claude_env: | 63 | # NODE_ENV: test 64 | 65 | -------------------------------------------------------------------------------- /Sources/StateGraphMacro/GraphViewMacro.swift: -------------------------------------------------------------------------------- 1 | import SwiftCompilerPlugin 2 | import SwiftSyntax 3 | import SwiftSyntaxBuilder 4 | import SwiftSyntaxMacros 5 | 6 | public struct GraphViewMacro: Macro { 7 | 8 | public enum Error: Swift.Error { 9 | case needsTypeAnnotation 10 | case notFoundPropertyName 11 | } 12 | 13 | public static var formatMode: FormatMode { 14 | .auto 15 | } 16 | 17 | } 18 | 19 | extension GraphViewMacro: MemberAttributeMacro { 20 | 21 | public static func expansion( 22 | of node: AttributeSyntax, 23 | attachedTo declaration: some DeclGroupSyntax, 24 | providingAttributesFor member: some DeclSyntaxProtocol, 25 | in context: some MacroExpansionContext 26 | ) throws -> [AttributeSyntax] { 27 | 28 | guard let variableDecl = member.as(VariableDeclSyntax.self) else { 29 | return [] 30 | } 31 | 32 | let existingAttributes = variableDecl.attributes.map { $0.trimmed.description } 33 | 34 | let ignoreMacros = Set( 35 | [ 36 | "@GraphStored", 37 | "@GraphComputed", 38 | "@GraphIgnored", 39 | ] 40 | ) 41 | 42 | if existingAttributes.filter({ ignoreMacros.contains($0) }).count > 0 { 43 | return [] 44 | } 45 | 46 | guard variableDecl.bindingSpecifier.tokenKind == .keyword(.var) else { 47 | return [] 48 | } 49 | 50 | if variableDecl.isComputed { 51 | return [ 52 | ] 53 | } else { 54 | return [AttributeSyntax(stringLiteral: "@GraphStored")] 55 | } 56 | 57 | } 58 | } 59 | 60 | extension GraphViewMacro: MemberMacro { 61 | 62 | public static func expansion( 63 | of node: AttributeSyntax, 64 | providingMembersOf declaration: some DeclGroupSyntax, 65 | conformingTo protocols: [TypeSyntax], 66 | in context: some MacroExpansionContext 67 | ) throws -> [DeclSyntax] { 68 | return [] 69 | } 70 | 71 | } 72 | 73 | extension GraphViewMacro: ExtensionMacro { 74 | public static func expansion( 75 | of node: AttributeSyntax, 76 | attachedTo declaration: some DeclGroupSyntax, 77 | providingExtensionsOf type: some TypeSyntaxProtocol, 78 | conformingTo protocols: [TypeSyntax], 79 | in context: some MacroExpansionContext 80 | ) throws -> [ExtensionDeclSyntax] { 81 | 82 | guard let classDecl = declaration.as(ClassDeclSyntax.self) else { 83 | fatalError() 84 | } 85 | 86 | return [ 87 | (""" 88 | extension \(classDecl.name.trimmed): GraphViewType { 89 | } 90 | """ as DeclSyntax).cast(ExtensionDeclSyntax.self) 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/StaticPropertyTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import StateGraph 3 | 4 | @Suite 5 | struct StaticPropertyTests { 6 | 7 | final class ModelWithStatic { 8 | @GraphStored 9 | static var sharedValue: Int = 0 10 | 11 | @GraphStored 12 | var instanceValue: Int = 0 13 | } 14 | 15 | @Test @MainActor func static_property_compilation() { 16 | // Test if static @GraphStored properties compile and work correctly 17 | ModelWithStatic.sharedValue = 10 18 | #expect(ModelWithStatic.sharedValue == 10) 19 | 20 | ModelWithStatic.sharedValue = 20 21 | #expect(ModelWithStatic.sharedValue == 20) 22 | 23 | // Test instance property still works 24 | let instance = ModelWithStatic() 25 | instance.instanceValue = 5 26 | #expect(instance.instanceValue == 5) 27 | } 28 | 29 | @Test @MainActor func static_property_reactivity() async { 30 | // Reset static value 31 | ModelWithStatic.sharedValue = 0 32 | 33 | await confirmation(expectedCount: 2) { c in 34 | let cancellable = withGraphTracking { 35 | withGraphTrackingMap { 36 | ModelWithStatic.$sharedValue.wrappedValue 37 | } onChange: { value in 38 | if value == 10 { 39 | c.confirm() 40 | } else if value == 42 { 41 | c.confirm() 42 | } 43 | } 44 | } 45 | 46 | // Wait a bit before changing values 47 | try? await Task.sleep(for: .milliseconds(10)) 48 | 49 | ModelWithStatic.sharedValue = 10 50 | 51 | try? await Task.sleep(for: .milliseconds(10)) 52 | 53 | ModelWithStatic.sharedValue = 42 54 | 55 | try? await Task.sleep(for: .milliseconds(10)) 56 | 57 | withExtendedLifetime(cancellable, {}) 58 | } 59 | } 60 | 61 | // TODO: @GraphComputed doesn't support static properties yet 62 | // This test is commented out until that functionality is added 63 | /* 64 | @Test func static_property_computed_dependency() { 65 | final class ModelWithComputedStatic { 66 | @GraphStored 67 | static var baseValue: Int = 10 68 | 69 | @GraphComputed 70 | static var doubledValue: Int 71 | 72 | static func initialize() { 73 | Self.$doubledValue = .init { _ in 74 | Self.baseValue * 2 75 | } 76 | } 77 | } 78 | 79 | ModelWithComputedStatic.initialize() 80 | 81 | #expect(ModelWithComputedStatic.doubledValue == 20) 82 | 83 | ModelWithComputedStatic.baseValue = 15 84 | #expect(ModelWithComputedStatic.doubledValue == 30) 85 | } 86 | */ 87 | } 88 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/StateGraph.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/StateGraphNormalization.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Sources/StateGraph/Observation/Filter.swift: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | A protocol for filtering values in node observation. 4 | 5 | Filters allow you to control which values trigger change notifications by implementing 6 | custom filtering logic. The filter's `send` method is called with each new value and 7 | can decide whether to pass it through or suppress the notification. 8 | */ 9 | public protocol Filter { 10 | 11 | associatedtype Value 12 | 13 | /// Processes a value and returns it if it should be passed to the handler, or nil to suppress the notification. 14 | /// - Parameter value: The new value from the node 15 | /// - Returns: The value to pass to the change handler, or nil to suppress 16 | /// the handler call. Returning nil does NOT affect Observation notifications 17 | /// (used by SwiftUI) - those are triggered at the node level when the node 18 | /// becomes potentially dirty. 19 | mutating func send(value: Value) -> Value? 20 | } 21 | 22 | /** 23 | A filter that passes through all values without any filtering. 24 | 25 | This is the default filter used when no explicit filter is specified. 26 | Every value change will trigger the onChange handler. 27 | */ 28 | public struct PassthroughFilter: Filter { 29 | 30 | public func send(value: Value) -> Value? { 31 | return value 32 | } 33 | 34 | public init() {} 35 | } 36 | 37 | /** 38 | A filter that only passes through values that are different from the previous value. 39 | 40 | This filter uses equality comparison to suppress duplicate onChange handler calls, 41 | which is useful for avoiding unnecessary work when the actual value hasn't changed. 42 | 43 | Note: This only affects onChange handler calls. SwiftUI and other Observation 44 | consumers will still receive notifications when the node becomes potentially dirty, 45 | allowing them to check for updates. This is the correct behavior because: 46 | - SwiftUI needs to know a value *might* have changed to trigger re-evaluation 47 | - onChange handlers should only run when values *actually* changed 48 | 49 | ```swift 50 | withGraphTracking { 51 | withGraphTrackingMap( 52 | { node.wrappedValue }, 53 | filter: DistinctFilter() 54 | ) { value in 55 | // Only called when value actually changes 56 | print("New value: \(value)") 57 | } 58 | } 59 | ``` 60 | 61 | Note: When using `withGraphTrackingMap` with `Equatable` types, `DistinctFilter` 62 | is automatically applied, so you typically don't need to specify it explicitly. 63 | */ 64 | public struct DistinctFilter: Filter { 65 | 66 | private var lastValue: Value? 67 | 68 | public mutating func send(value: Value) -> Value? { 69 | guard value != lastValue else { return nil } 70 | lastValue = value 71 | return value 72 | } 73 | 74 | public init() {} 75 | } 76 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Development Commands 6 | 7 | ### Building and Testing 8 | ```bash 9 | # Build the package 10 | swift build 11 | 12 | # Run all tests 13 | swift test 14 | 15 | # Run specific test file or test 16 | swift test --filter StateGraphTests.NodeObserveTests 17 | 18 | # Build for release 19 | swift build -c release 20 | 21 | # Clean build artifacts 22 | swift package clean 23 | ``` 24 | 25 | ### Package Management 26 | ```bash 27 | # Update dependencies 28 | swift package update 29 | 30 | # Resolve dependencies 31 | swift package resolve 32 | 33 | # Generate Xcode project (if needed) 34 | swift package generate-xcodeproj 35 | ``` 36 | 37 | ## Architecture Overview 38 | 39 | Swift State Graph is a reactive state management library that uses a Directed Acyclic Graph (DAG) to manage data flow and dependencies. The architecture consists of: 40 | 41 | ### Core Components 42 | 43 | 1. **Node System** - The foundation of the reactive graph: 44 | - `Node`: Type-safe node protocol for storing and computing values 45 | - `Stored`: Mutable nodes that serve as sources of truth 46 | - `Computed`: Read-only nodes that derive values from other nodes 47 | - `Edge`: Represents dependencies between nodes with automatic cleanup 48 | 49 | 2. **Macro System** - Swift macros for cleaner syntax: 50 | - `@GraphStored`: Property wrapper for stored values 51 | - `@GraphComputed`: Property wrapper for computed values 52 | - `@GraphIgnored`: Marks properties to be ignored by observation 53 | - `@GraphView`: Generates view logic for state management 54 | 55 | 3. **Storage Abstraction** - Flexible backing storage: 56 | - `InMemoryStorage`: Default volatile storage 57 | - `UserDefaultsStorage`: Persistent storage backed by UserDefaults 58 | - Protocol-based design allows custom storage implementations 59 | 60 | 4. **Integration Points**: 61 | - **SwiftUI**: `GraphObject` protocol for Environment propagation, binding support 62 | - **UIKit**: `withGraphTracking` for reactive updates in UIKit views 63 | - **Observable**: Compatible with Swift's Observable protocol (iOS 17+) 64 | 65 | ### Key Design Principles 66 | 67 | - **Automatic Dependency Tracking**: Nodes automatically track which other nodes they depend on 68 | - **Lazy Evaluation**: Computed values only recalculate when accessed after dependencies change 69 | - **Thread Safety**: Uses `NSRecursiveLock` for concurrent access protection 70 | - **Memory Management**: Weak references and automatic edge cleanup prevent retain cycles 71 | 72 | ### Module Structure 73 | 74 | - `StateGraph`: Core reactive graph implementation 75 | - `StateGraphMacro`: Swift macro implementations 76 | - `StateGraphNormalization`: Data normalization for relational data management 77 | 78 | The library emphasizes declarative state management with minimal boilerplate while maintaining type safety and performance. -------------------------------------------------------------------------------- /Sources/StateGraph/Observation/withGraphTrackingGroup.swift: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | Group tracking for reactive processing within a graph tracking scope. 4 | 5 | This function enables Computed-like reactive processing where code is executed immediately 6 | and re-executed whenever any accessed nodes change. Unlike Computed nodes which return values, 7 | this executes side effects and operations based on node values, dynamically tracking only 8 | the nodes that are actually accessed during execution. 9 | 10 | ## Behavior 11 | - Must be called within a `withGraphTracking` scope 12 | - The handler closure is executed initially and re-executed whenever any tracked node changes 13 | - Only nodes accessed during execution are tracked for the next iteration 14 | - Nodes are dynamically added/removed from tracking based on runtime conditions 15 | 16 | ## Example: Group Tracking 17 | ```swift 18 | let condition = Stored(wrappedValue: 5) 19 | let conditionalNode = Stored(wrappedValue: 10) 20 | let alwaysNode = Stored(wrappedValue: 20) 21 | 22 | withGraphTracking { 23 | withGraphTrackingGroup { 24 | // alwaysNode is always tracked 25 | print("Always: \(alwaysNode.wrappedValue)") 26 | 27 | // conditionalNode is only tracked when condition > 10 28 | if condition.wrappedValue > 10 { 29 | print("Conditional access: \(conditionalNode.wrappedValue)") 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | In this example: 36 | - `alwaysNode` changes will always trigger re-execution 37 | - `conditionalNode` changes only trigger re-execution when `condition > 10` 38 | - `condition` changes will trigger re-execution (to re-evaluate the condition) 39 | 40 | ## Use Cases 41 | - Feature flags: Only track relevant nodes when features are enabled 42 | - UI state: Track different nodes based on current screen/mode 43 | - Performance optimization: Avoid expensive tracking when not needed 44 | - Dynamic dependency graphs: Build reactive systems that adapt to runtime conditions 45 | 46 | - Parameter handler: The closure to execute with conditional tracking 47 | - Parameter isolation: Actor isolation context for execution 48 | */ 49 | public func withGraphTrackingGroup( 50 | _ handler: @escaping () -> Void, 51 | isolation: isolated (any Actor)? = #isolation 52 | ) { 53 | 54 | guard ThreadLocal.subscriptions.value != nil else { 55 | assertionFailure("You must call withGraphTracking before calling this method.") 56 | return 57 | } 58 | 59 | let _handlerBox = OSAllocatedUnfairLock<(() -> Void)?>(uncheckedState: handler) 60 | 61 | withContinuousStateGraphTracking( 62 | apply: { 63 | _handlerBox.withLock { $0?() } 64 | }, 65 | didChange: { 66 | guard !_handlerBox.withLock({ $0 == nil }) else { return .stop } 67 | return .next 68 | }, 69 | isolation: isolation 70 | ) 71 | 72 | let cancellabe = AnyCancellable { 73 | _handlerBox.withLock { $0 = nil } 74 | } 75 | 76 | ThreadLocal.subscriptions.value!.append(cancellabe) 77 | 78 | } 79 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/Syntax.swift: -------------------------------------------------------------------------------- 1 | import StateGraph 2 | 3 | #if DEBUG 4 | 5 | import os.lock 6 | 7 | // Test top-level property - this should not have init accessor but should have get/set 8 | @GraphStored 9 | private var testTopLevel: Int = 123 10 | 11 | private enum Static { 12 | @GraphStored 13 | static var staticValue: Int = 0 14 | 15 | @GraphStored 16 | static var staticValue2: String? = nil 17 | } 18 | 19 | final class UserDefaultsModel { 20 | 21 | @GraphStored(backed: .userDefaults(key: "value")) var value: Int = 0 22 | @GraphStored(backed: .userDefaults(key: "value2")) var value2: String? = nil 23 | @GraphStored(backed: .userDefaults(key: "maxRetries")) var maxRetries: Int = 3 24 | 25 | } 26 | 27 | final class PrivateExample { 28 | 29 | @GraphStored 30 | private var private_value: Int = 0 31 | 32 | @GraphStored 33 | private(set) var _private_set_value: Int = 0 34 | 35 | } 36 | 37 | // MARK: - Unified Syntax Demo 38 | 39 | final class UnifiedSyntaxDemo { 40 | 41 | // Memory storage (default) 42 | @GraphStored var count: Int = 0 43 | @GraphStored var name: String? 44 | 45 | // UserDefaults storage 46 | @GraphStored(backed: .userDefaults(key: "theme")) var theme: String = "light" 47 | @GraphStored(backed: .userDefaults(key: "isEnabled")) var isEnabled: Bool = true 48 | 49 | // UserDefaults with suite 50 | @GraphStored(backed: .userDefaults(suite: "com.example.app", key: "apiUrl")) var apiUrl: String = "https://api.example.com" 51 | 52 | } 53 | 54 | final class ImplicitInitializers { 55 | @GraphStored 56 | var value: Int? = nil 57 | 58 | @GraphStored 59 | var value2: Int! = nil 60 | 61 | init() { 62 | 63 | } 64 | 65 | static func run() { 66 | _ = ImplicitInitializers() 67 | } 68 | } 69 | 70 | final class Box { 71 | var value: T 72 | init(_ value: T) { 73 | self.value = value 74 | } 75 | } 76 | 77 | class Ref {} 78 | 79 | final class Demo { 80 | 81 | @GraphStored 82 | var optional_variable_init: Int? = nil 83 | 84 | } 85 | 86 | final class StateViewModel { 87 | 88 | let constant_init: Int = 0 89 | 90 | var variable_init: Int = 0 91 | 92 | let optional_constant_init: Int? = 0 93 | 94 | @GraphStored 95 | var optional_variable_init: Int? = 0 96 | 97 | let optional_constant: Int? 98 | 99 | @GraphStored 100 | var optional_variable: Int? 101 | 102 | var computed: Int { 103 | 0 104 | } 105 | 106 | @GraphIgnored 107 | weak var weak_variable: AnyObject? 108 | 109 | @GraphIgnored 110 | unowned var unowned_variable: AnyObject 111 | 112 | unowned let unowned_constant: AnyObject 113 | 114 | init() { 115 | self.optional_constant = 0 116 | self.optional_variable = 0 117 | unowned_constant = NSObject() 118 | self.unowned_variable = NSObject() 119 | 120 | } 121 | 122 | } 123 | 124 | #endif 125 | -------------------------------------------------------------------------------- /Development/Development.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "beef671bc10fe5c3c2f89b4b915468b878b171f733d8877ff1127fc7d0e3cbc0", 3 | "pins" : [ 4 | { 5 | "identity" : "descriptors", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/FluidGroup/Descriptors", 8 | "state" : { 9 | "revision" : "1ec4fcd01b7e3a6df9dfafcca8155e297a7a1d9f", 10 | "version" : "0.3.0" 11 | } 12 | }, 13 | { 14 | "identity" : "resultbuilderkit", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/FluidGroup/ResultBuilderKit", 17 | "state" : { 18 | "revision" : "34aa57fcee5ec3ee0f368b05cb53c7919c188ab2", 19 | "version" : "1.3.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-storybook", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/eure/swift-storybook.git", 26 | "state" : { 27 | "revision" : "d964f81a4611dbb415e8fcf2cf558833a175c59b", 28 | "version" : "2.7.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-syntax", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/swiftlang/swift-syntax.git", 35 | "state" : { 36 | "revision" : "4799286537280063c85a32f09884cfbca301b1a1", 37 | "version" : "602.0.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-typed-identifier", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/VergeGroup/swift-typed-identifier.git", 44 | "state" : { 45 | "revision" : "ea7afa2ce943c6bf3ef87d385c8a6e9f67fea4f3", 46 | "version" : "2.0.4" 47 | } 48 | }, 49 | { 50 | "identity" : "swiftui-support", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/FluidGroup/swiftui-support", 53 | "state" : { 54 | "revision" : "fbc3f1770889f74818a56eea3d7099a0d3a9c5e1", 55 | "version" : "0.13.0" 56 | } 57 | }, 58 | { 59 | "identity" : "texture", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/FluidGroup/Texture.git", 62 | "state" : { 63 | "revision" : "f6aba697c93e955b822e1bed148e83eee2db70e4", 64 | "version" : "3.0.3" 65 | } 66 | }, 67 | { 68 | "identity" : "texturebridging", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/FluidGroup/TextureBridging.git", 71 | "state" : { 72 | "revision" : "4383f8a9846a0507d2c22ee9fac22153f9b86fed", 73 | "version" : "3.2.1" 74 | } 75 | }, 76 | { 77 | "identity" : "textureswiftsupport", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/FluidGroup/TextureSwiftSupport.git", 80 | "state" : { 81 | "revision" : "ce3319351831533330e22d205bd83937848059db", 82 | "version" : "3.24.0" 83 | } 84 | } 85 | ], 86 | "version" : 3 87 | } 88 | -------------------------------------------------------------------------------- /Sources/StateGraph/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``StateGraph`` 2 | 3 | State management framework for reactive programming with automatic dependency tracking. 4 | 5 | ## Overview 6 | 7 | Swift State Graph is a framework designed for managing application state using a graph-based approach. It provides tools for creating and managing stored and computed properties, enabling efficient and reactive data flow within an application. 8 | 9 | Unlike traditional state management approaches, Swift State Graph automatically tracks dependencies between your data and updates dependent values reactively. This eliminates manual state synchronization and reduces bugs while improving performance through intelligent caching and lazy evaluation. 10 | 11 | ### Key Features 12 | 13 | - **Automatic Dependency Tracking**: The framework automatically detects when one value depends on another 14 | - **Reactive Updates**: Changes propagate automatically through the dependency graph 15 | - **Computed Properties**: Define derived values that update automatically when their dependencies change 16 | - **SwiftUI Integration**: Seamless integration with SwiftUI's binding system 17 | - **Memory Efficient**: Lazy evaluation and intelligent caching optimize performance 18 | - **Type Safe**: Full Swift type safety with compile-time dependency verification 19 | 20 | ### Quick Example 21 | 22 | ```swift 23 | import StateGraph 24 | 25 | final class CounterViewModel { 26 | @GraphStored 27 | var count: Int = 0 28 | 29 | @GraphComputed 30 | var isEven: Bool 31 | 32 | init() { 33 | self.$isEven = .init { [$count] _ in 34 | $count.wrappedValue % 2 == 0 35 | } 36 | } 37 | 38 | func increment() { 39 | count += 1 // isEven automatically updates 40 | } 41 | } 42 | ``` 43 | 44 | ## Topics 45 | 46 | ### Getting Started 47 | 48 | - 49 | - 50 | - 51 | 52 | ### Core Components 53 | 54 | - ``Stored`` 55 | - ``Computed`` 56 | - ``Node`` 57 | - ``GraphStored`` 58 | - ``GraphComputed`` 59 | 60 | ### Persistence and Storage 61 | 62 | - 63 | - ``GraphStorageBacking`` 64 | 65 | ### Building Reactive Models 66 | 67 | - 68 | - 69 | - 70 | 71 | ### Framework Integration 72 | 73 | - 74 | - 75 | - 76 | 77 | ### Advanced Usage 78 | 79 | - 80 | - 81 | - 82 | 83 | ### Migration and Adoption 84 | 85 | - 86 | - 87 | 88 | ### Data Normalization 89 | 90 | - ``EntityStore`` 91 | - ``TypedIdentifiable`` 92 | - 93 | 94 | ### Observation and Tracking 95 | 96 | - ``withGraphTracking(_:)`` 97 | - ``withGraphTrackingGroup(_:)`` 98 | - ``Node/onChange(_:)`` 99 | - 100 | 101 | ### Utilities 102 | 103 | - ``NodeStore`` 104 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: macos-15 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@beta 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | 40 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 41 | # model: "claude-opus-4-20250514" 42 | 43 | # Direct prompt for automated review (no @claude mention needed) 44 | direct_prompt: | 45 | Please review this pull request and provide feedback on: 46 | - Code quality and best practices 47 | - Potential bugs or issues 48 | - Performance considerations 49 | - Security concerns 50 | - Test coverage 51 | 52 | Be constructive and helpful in your feedback. 53 | 54 | # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR 55 | use_sticky_comment: true 56 | 57 | # Optional: Customize review based on file types 58 | # direct_prompt: | 59 | # Review this PR focusing on: 60 | # - For TypeScript files: Type safety and proper interface usage 61 | # - For API endpoints: Security, input validation, and error handling 62 | # - For React components: Performance, accessibility, and best practices 63 | # - For tests: Coverage, edge cases, and test quality 64 | 65 | # Optional: Different prompts for different authors 66 | # direct_prompt: | 67 | # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && 68 | # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || 69 | # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} 70 | 71 | # Optional: Add specific tools for running tests or linting 72 | # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" 73 | 74 | # Optional: Skip review for certain conditions 75 | # if: | 76 | # !contains(github.event.pull_request.title, '[skip-review]') && 77 | # !contains(github.event.pull_request.title, '[WIP]') 78 | 79 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/ActorIsolationTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @preconcurrency import StateGraph 3 | import Foundation 4 | 5 | @Suite("Actor Isolation Tests") 6 | struct ActorIsolationTests { 7 | 8 | final class ThreadTracker: @unchecked Sendable { 9 | private let lock = NSLock() 10 | private var _callsOnMainActor: Int = 0 11 | private var _callsOnBackground: Int = 0 12 | 13 | var callsOnMainActor: Int { 14 | lock.lock() 15 | defer { lock.unlock() } 16 | return _callsOnMainActor 17 | } 18 | 19 | var callsOnBackground: Int { 20 | lock.lock() 21 | defer { lock.unlock() } 22 | return _callsOnBackground 23 | } 24 | 25 | func recordCall() { 26 | lock.lock() 27 | defer { lock.unlock() } 28 | 29 | if Thread.isMainThread { 30 | _callsOnMainActor += 1 31 | } else { 32 | _callsOnBackground += 1 33 | } 34 | } 35 | 36 | func reset() { 37 | lock.lock() 38 | defer { lock.unlock() } 39 | _callsOnMainActor = 0 40 | _callsOnBackground = 0 41 | } 42 | } 43 | 44 | @Test @MainActor 45 | func withGraphTrackingGroupPreservesMainActorIsolation() async throws { 46 | let node = Stored(wrappedValue: 0) 47 | let tracker = ThreadTracker() 48 | 49 | // Start withGraphTrackingGroup on MainActor 50 | let cancellable = withGraphTracking { 51 | withGraphTrackingGroup { 52 | tracker.recordCall() 53 | print("=== withGraphTrackingGroup called, value: \(node.wrappedValue), isMainThread: \(Thread.isMainThread) ===") 54 | _ = node.wrappedValue // Track the node 55 | } 56 | } 57 | 58 | // Initial call should be on MainActor 59 | #expect(tracker.callsOnMainActor == 1) 60 | #expect(tracker.callsOnBackground == 0) 61 | 62 | // Update from background thread 63 | await Task.detached { 64 | node.wrappedValue = 1 65 | }.value 66 | 67 | try await Task.sleep(nanoseconds: 200_000_000) 68 | 69 | // The handler should still be called on MainActor even though update came from background 70 | #expect(tracker.callsOnMainActor == 2) 71 | #expect(tracker.callsOnBackground == 0) 72 | 73 | cancellable.cancel() 74 | } 75 | 76 | @Test @MainActor 77 | func onChangePreservesMainActorIsolation() async throws { 78 | let node = Stored(wrappedValue: 0) 79 | let tracker = ThreadTracker() 80 | 81 | // Start onChange on MainActor 82 | let cancellable = withGraphTracking { 83 | withGraphTrackingMap { 84 | node.wrappedValue 85 | } onChange: { value in 86 | tracker.recordCall() 87 | print("=== onChange called, value: \(value), isMainThread: \(Thread.isMainThread) ===") 88 | } 89 | } 90 | 91 | try await Task.sleep(nanoseconds: 100_000_000) 92 | 93 | // Initial call should be on MainActor 94 | #expect(tracker.callsOnMainActor == 1) 95 | #expect(tracker.callsOnBackground == 0) 96 | 97 | // Update from background thread 98 | await Task.detached { 99 | node.wrappedValue = 1 100 | }.value 101 | 102 | try await Task.sleep(nanoseconds: 200_000_000) 103 | 104 | // The onChange callback should still be called on MainActor 105 | #expect(tracker.callsOnMainActor == 2) 106 | #expect(tracker.callsOnBackground == 0) 107 | 108 | cancellable.cancel() 109 | } 110 | } -------------------------------------------------------------------------------- /Development/Development.xcodeproj/xcshareddata/xcschemes/Development.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 58 | 59 | 63 | 64 | 65 | 66 | 72 | 74 | 80 | 81 | 82 | 83 | 85 | 86 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /Development/Development/ListView.swift: -------------------------------------------------------------------------------- 1 | //// 2 | //// ListView.swift 3 | //// Development 4 | //// 5 | //// Created by Muukii on 2025/04/28. 6 | //// 7 | // 8 | //import StateGraph 9 | //import SwiftUI 10 | // 11 | //final class TagEntity: Sendable { 12 | // 13 | // let id: String 14 | // 15 | // @GraphStored 16 | // var name: String = "" 17 | // 18 | // init( 19 | // id: String, 20 | // name: String 21 | // ) { 22 | // self.id = id 23 | // self.name = name 24 | // } 25 | //} 26 | // 27 | //final class AuthorEntity: Sendable { 28 | // 29 | // let id: String 30 | // 31 | // @GraphStored 32 | // var name: String = "" 33 | // 34 | // init( 35 | // id: String, 36 | // name: String 37 | // ) { 38 | // self.id = id 39 | // self.name = name 40 | // } 41 | // 42 | //} 43 | // 44 | //final class BookEntity: Sendable { 45 | // 46 | // let id: String 47 | // 48 | // @GraphStored 49 | // var title: String 50 | // 51 | // @GraphStored 52 | // var author: AuthorEntity 53 | // 54 | // @GraphComputed 55 | // var tags: [TagEntity] = [] 56 | // 57 | // init( 58 | // id: String, 59 | // title: String, 60 | // author: AuthorEntity, 61 | // tags: [TagEntity] 62 | // ) { 63 | // self.id = id 64 | // self.title = title 65 | // self.author = author 66 | // self.tags = tags 67 | // } 68 | //} 69 | // 70 | //@StateView 71 | //final class RootState: Sendable { 72 | // 73 | // @StateView 74 | // final class DB: Sendable { 75 | // var books: [BookEntity] = [] 76 | // var authors: [AuthorEntity] = [] 77 | // } 78 | // 79 | // let db: DB 80 | // 81 | // init() { 82 | // 83 | // self.db = .init() 84 | // 85 | // } 86 | // 87 | //} 88 | // 89 | //struct ListView: View { 90 | // 91 | // let rootState: RootState 92 | // 93 | // init(rootState: RootState) { 94 | // self.rootState = rootState 95 | // } 96 | // 97 | // var body: some View { 98 | // NavigationStack { 99 | // List(rootState.db.authors, id: \.self) { e in 100 | // NavigationLink(value: e) { 101 | // AuthorCell(author: e) 102 | // } 103 | // } 104 | // .navigationDestination(for: AuthorEntity.self, destination: { author in 105 | // BookListInAuthor(author: author, rootState: rootState) 106 | // }) 107 | // .toolbar { 108 | // Button("Add Author") { 109 | // 110 | // rootState.db.authors.append( 111 | // .init( 112 | // id: UUID().uuidString, 113 | // name: "Unknown" 114 | // ) 115 | // ) 116 | // 117 | // } 118 | // } 119 | // } 120 | // } 121 | // 122 | // struct BookListInAuthor: View { 123 | // 124 | // @Computed var books: [BookEntity] 125 | // let rootState: RootState 126 | // let author: AuthorEntity 127 | // 128 | // init( 129 | // author: AuthorEntity, 130 | // rootState: RootState 131 | // ) { 132 | // self.author = author 133 | // self.rootState = rootState 134 | // 135 | // _books = .init(compute: { 136 | // rootState.db.books 137 | // .filter { $0.author.id == author.id } 138 | // .sorted { $0.title < $1.title } 139 | // }) 140 | // } 141 | // 142 | // var body: some View { 143 | // List(books, id: \.self) { e in 144 | // NavigationLink(value: e) { 145 | // BookCell(book: e) 146 | // } 147 | // } 148 | // .toolbar { 149 | // Button("Add Book") { 150 | // 151 | // rootState.db.books.append( 152 | // .init( 153 | // id: UUID().uuidString, 154 | // title: "New", 155 | // author: author, 156 | // tags: [] 157 | // ) 158 | // ) 159 | // } 160 | // } 161 | // } 162 | // 163 | // } 164 | // 165 | // 166 | // struct AuthorCell: View { 167 | // 168 | // let author: AuthorEntity 169 | // 170 | // var body: some View { 171 | // Text(author.name) 172 | // } 173 | // 174 | // } 175 | // 176 | // struct BookCell: View { 177 | // 178 | // let book: BookEntity 179 | // 180 | // var body: some View { 181 | // Text(book.title) 182 | // } 183 | // 184 | // } 185 | //} 186 | // 187 | //#Preview { 188 | // ListView(rootState: RootState()) 189 | //} 190 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/GraphTrackingCancellationTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import StateGraph 5 | 6 | @Suite("GraphTrackingCancellation Tests") 7 | struct GraphTrackingCancellationTests { 8 | 9 | final class Resource { 10 | deinit { 11 | 12 | } 13 | } 14 | 15 | @Test 16 | func resourceReleasingGroup() { 17 | 18 | let node = Stored(wrappedValue: 0) 19 | 20 | let pointer = Unmanaged.passRetained(Resource()) 21 | 22 | weak var resourceRef: Resource? = pointer.takeUnretainedValue() 23 | 24 | let subscription = withGraphTracking { 25 | withGraphTrackingGroup { [resource = pointer.takeUnretainedValue()] in 26 | print(node.wrappedValue) 27 | print(resource) 28 | } 29 | } 30 | 31 | pointer.release() 32 | 33 | #expect(resourceRef != nil) 34 | 35 | subscription.cancel() 36 | 37 | #expect(resourceRef == nil) 38 | 39 | } 40 | 41 | @Test 42 | func resourceReleasingMap() { 43 | 44 | let node = Stored(wrappedValue: 0) 45 | 46 | let pointer = Unmanaged.passRetained(Resource()) 47 | 48 | weak var resourceRef: Resource? = pointer.takeUnretainedValue() 49 | 50 | let subscription = withGraphTracking { 51 | withGraphTrackingMap( 52 | { [resource = pointer.takeUnretainedValue()] in 53 | print(node.wrappedValue) 54 | print(resource) 55 | return node.wrappedValue 56 | } 57 | ) { value in 58 | print("Value: \(value)") 59 | } 60 | } 61 | 62 | pointer.release() 63 | 64 | #expect(resourceRef != nil) 65 | 66 | subscription.cancel() 67 | 68 | #expect(resourceRef == nil) 69 | 70 | } 71 | 72 | @Test 73 | func resourceReleasingMapDependency() { 74 | 75 | class ViewModel { 76 | let node = Stored(wrappedValue: 0) 77 | } 78 | 79 | let viewModel = ViewModel() 80 | 81 | let pointer = Unmanaged.passRetained(Resource()) 82 | 83 | weak var resourceRef: Resource? = pointer.takeUnretainedValue() 84 | 85 | let subscription = withGraphTracking { 86 | withGraphTrackingMap( 87 | from: viewModel, 88 | map: { [resource = pointer.takeUnretainedValue()] vm in 89 | print(vm.node.wrappedValue) 90 | print(resource) 91 | return vm.node.wrappedValue 92 | } 93 | ) { value in 94 | print("Value: \(value)") 95 | } 96 | } 97 | 98 | pointer.release() 99 | 100 | #expect(resourceRef != nil) 101 | 102 | subscription.cancel() 103 | 104 | #expect(resourceRef == nil) 105 | 106 | } 107 | 108 | @Test 109 | func resourceReleasingMapDependencyOnChange() { 110 | 111 | class ViewModel { 112 | let node = Stored(wrappedValue: 0) 113 | } 114 | 115 | let viewModel = ViewModel() 116 | 117 | let pointer = Unmanaged.passRetained(Resource()) 118 | 119 | weak var resourceRef: Resource? = pointer.takeUnretainedValue() 120 | 121 | let subscription = withGraphTracking { 122 | withGraphTrackingMap( 123 | from: viewModel, 124 | map: { vm in vm.node.wrappedValue } 125 | ) { [resource = pointer.takeUnretainedValue()] value in 126 | print("Value: \(value)") 127 | print(resource) 128 | } 129 | } 130 | 131 | pointer.release() 132 | 133 | #expect(resourceRef != nil) 134 | 135 | subscription.cancel() 136 | 137 | #expect(resourceRef == nil) 138 | 139 | } 140 | 141 | @Test 142 | func viewModelNotRetained() { 143 | 144 | class ViewModel { 145 | let node = Stored(wrappedValue: 0) 146 | deinit { 147 | print("ViewModel deinit") 148 | } 149 | } 150 | 151 | let pointer = Unmanaged.passRetained(ViewModel()) 152 | 153 | weak var viewModelRef: ViewModel? = pointer.takeUnretainedValue() 154 | 155 | let subscription = withGraphTracking { 156 | withGraphTrackingMap( 157 | from: pointer.takeUnretainedValue(), 158 | map: { vm in vm.node.wrappedValue } 159 | ) { value in 160 | print("Value: \(value)") 161 | } 162 | } 163 | 164 | // viewModel should be retained only by pointer, not by withGraphTrackingMap 165 | #expect(viewModelRef != nil) 166 | 167 | pointer.release() 168 | 169 | // After releasing, viewModel should be deallocated 170 | // because withGraphTrackingMap doesn't retain it 171 | #expect(viewModelRef == nil) 172 | 173 | subscription.cancel() 174 | 175 | } 176 | 177 | } 178 | -------------------------------------------------------------------------------- /Development/Development/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Development 4 | // 5 | // Created by Muukii on 2025/04/28. 6 | // 7 | 8 | import StateGraph 9 | import SwiftUI 10 | import StorybookKit 11 | 12 | struct ContentView: View { 13 | var body: some View { 14 | NavigationStack { 15 | Form { 16 | 17 | NavigationLink { 18 | Observe_specific(model: .init(name: "A", count: 1)) 19 | } label: { 20 | Text("Observe specific") 21 | } 22 | 23 | NavigationLink { 24 | Text("Empty") 25 | } label: { 26 | Text("Empty") 27 | } 28 | 29 | NavigationLink { 30 | Book_StateView(entity: .init(name: "A", count: 1)) 31 | } label: { 32 | Text("StateView") 33 | } 34 | 35 | NavigationLink { 36 | PostListContainerView() 37 | } label: { 38 | Text("Posts") 39 | } 40 | 41 | NavigationLink { 42 | PostOneShotView() 43 | } label: { 44 | Text("Database memory check") 45 | } 46 | 47 | NavigationLink { 48 | UserDefaultsView() 49 | } label: { 50 | Text("UserDefaults") 51 | } 52 | 53 | } 54 | } 55 | } 56 | } 57 | 58 | #if DEBUG 59 | 60 | import SwiftUI 61 | 62 | private struct Book_SingleStoredNode: View { 63 | 64 | let node: Stored 65 | 66 | init(node: Stored) { 67 | self.node = node 68 | } 69 | 70 | var body: some View { 71 | VStack { 72 | Text("\(node.wrappedValue)") 73 | Button("+1") { 74 | node.wrappedValue += 1 75 | } 76 | } 77 | } 78 | } 79 | 80 | #Preview("_Book") { 81 | Book_SingleStoredNode( 82 | node: Stored(wrappedValue: 1) 83 | ) 84 | .frame(width: 300, height: 300) 85 | } 86 | 87 | // MARK: - 88 | 89 | final class Model: Sendable { 90 | 91 | @GraphStored 92 | var name: String = "" 93 | @GraphStored 94 | var count1: Int = 0 95 | @GraphStored 96 | var count2: Int = 0 97 | 98 | init( 99 | name: String, 100 | count: Int 101 | ) { 102 | self.name = name 103 | self.count1 = count 104 | } 105 | 106 | } 107 | 108 | struct Observe_specific: View { 109 | 110 | let model: Model 111 | 112 | var body: some View { 113 | let _ = Self._printChanges() 114 | Form { 115 | Text("This view should be updated only when count1 changes.") 116 | Text("\(model.count1)") 117 | Button("Update count1") { 118 | model.count1 += 1 119 | } 120 | Button("Update count2") { 121 | model.count2 += 1 122 | } 123 | } 124 | } 125 | 126 | } 127 | 128 | #Preview("Observe_specific") { 129 | Observe_specific(model: .init(name: "A", count: 1)) 130 | } 131 | 132 | private struct Book_StateView: View { 133 | 134 | let model: Model 135 | @State var subscription: AnyCancellable? 136 | 137 | init(entity: Model) { 138 | self.model = entity 139 | } 140 | 141 | var body: some View { 142 | let _ = Self._printChanges() 143 | Form { 144 | Text("\(model.name)") 145 | Text("\(model.count1)") 146 | Button("Update Name") { 147 | model.name += "+" 148 | } 149 | Button("Update Count \(model.count1)") { 150 | model.count1 += 1 151 | } 152 | Button("Update Count \(model.count2)") { 153 | model.count2 += 1 154 | } 155 | Button("Batch") { 156 | model.count1 += 1 157 | model.count1 += 1 158 | model.count2 += 1 159 | } 160 | } 161 | .onAppear { 162 | 163 | print("onAppear") 164 | 165 | subscription = withGraphTracking { 166 | 167 | withGraphTrackingGroup { 168 | print("☁️", model.count1, model.count2) 169 | } 170 | 171 | withGraphTrackingMap { 172 | model.count1 + model.count2 173 | } onChange: { value in 174 | print("computed", value) 175 | } 176 | 177 | withGraphTrackingMap { 178 | model.$count1.wrappedValue 179 | } onChange: { value in 180 | print("count", value) 181 | } 182 | } 183 | 184 | } 185 | .onDisappear { 186 | subscription?.cancel() 187 | } 188 | } 189 | } 190 | 191 | final class MyViewModel { 192 | @GraphStored var count: Int = 0 193 | } 194 | 195 | #Preview("StateView") { 196 | Book_StateView( 197 | entity: .init( 198 | name: "A", 199 | count: 1 200 | ) 201 | ) 202 | } 203 | 204 | 205 | #endif 206 | -------------------------------------------------------------------------------- /Sources/StateGraph/SwiftUI.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | 3 | import SwiftUI 4 | 5 | // MARK: - GraphObject Protocol 6 | 7 | /// A marker protocol that enables objects to be propagated through SwiftUI's Environment system. 8 | /// This protocol itself has no functional meaning and serves only as a marker to indicate 9 | /// compatibility with SwiftUI's @Environment property wrapper. 10 | /// 11 | /// # How to use: 12 | /// ```swift 13 | /// class MyModel: GraphObject { 14 | /// @GraphStored var count: Int = 0 15 | /// } 16 | /// 17 | /// struct ContentView: View { 18 | /// let model = MyModel() 19 | /// 20 | /// var body: some View { 21 | /// ChildView() 22 | /// .environment(model) 23 | /// } 24 | /// } 25 | /// 26 | /// struct ChildView: View { 27 | /// @Environment(MyModel.self) var model 28 | /// 29 | /// var body: some View { 30 | /// Text("\(model.count)") 31 | /// } 32 | /// } 33 | /// ``` 34 | @available(iOS 17.0, *) 35 | public protocol GraphObject: Observable {} 36 | 37 | extension Stored { 38 | 39 | /** 40 | Creates a SwiftUI binding from the stored property. 41 | */ 42 | public var binding: Binding { 43 | .init( 44 | get: { self.wrappedValue }, 45 | set: { self.wrappedValue = $0 } 46 | ) 47 | } 48 | 49 | } 50 | 51 | #if false 52 | extension SwiftUI.View { 53 | 54 | public typealias Computed = SwiftUI_Computed 55 | public typealias Stored = SwiftUI_Stored 56 | } 57 | 58 | @propertyWrapper 59 | public struct SwiftUI_Computed: DynamicProperty { 60 | 61 | public var wrappedValue: Value { 62 | node.wrappedValue.wrappedValue 63 | } 64 | 65 | public var projectedValue: SwiftUI_Computed { 66 | return .init(node: node.wrappedValue) 67 | } 68 | 69 | private let node: ObjectEdge> 70 | 71 | public init( 72 | _ file: StaticString = #file, 73 | _ line: UInt = #line, 74 | _ column: UInt = #column, 75 | compute: @escaping @Sendable (inout Computed.Context) -> Value 76 | ) { 77 | self.node = .init( 78 | wrappedValue: .init( 79 | file, 80 | line, 81 | column, 82 | rule: compute 83 | )) 84 | } 85 | 86 | public init( 87 | node: Computed 88 | ) { 89 | self.node = .init(wrappedValue: node) 90 | } 91 | 92 | } 93 | 94 | @propertyWrapper 95 | public struct SwiftUI_Stored: DynamicProperty { 96 | 97 | public var wrappedValue: Value { 98 | get { node.wrappedValue.wrappedValue } 99 | nonmutating set { node.wrappedValue.wrappedValue = newValue } 100 | } 101 | 102 | public var projectedValue: SwiftUI_Stored { 103 | .init(node: node.wrappedValue) 104 | } 105 | 106 | private let node: ObjectEdge> 107 | 108 | public init( 109 | _ file: StaticString = #file, 110 | _ line: UInt = #line, 111 | _ column: UInt = #column, 112 | wrappedValue initialValue: Value 113 | ) { 114 | self.node = .init(wrappedValue: .init(wrappedValue: initialValue)) 115 | } 116 | 117 | public init( 118 | _ file: StaticString = #file, 119 | _ line: UInt = #line, 120 | _ column: UInt = #column, 121 | node: Stored 122 | ) { 123 | self.node = .init(wrappedValue: node) 124 | } 125 | 126 | } 127 | 128 | // TODO: replace with original 129 | @propertyWrapper 130 | private struct ObjectEdge: DynamicProperty { 131 | 132 | @State private var box: Box = .init() 133 | 134 | var wrappedValue: O { 135 | if let value = box.value { 136 | return value 137 | } else { 138 | box.value = factory() 139 | return box.value! 140 | } 141 | } 142 | 143 | private let factory: () -> O 144 | 145 | init(wrappedValue factory: @escaping @autoclosure () -> O) { 146 | self.factory = factory 147 | } 148 | 149 | private final class Box { 150 | var value: Value? 151 | } 152 | 153 | } 154 | 155 | #endif 156 | 157 | @available(iOS 17, *) 158 | #Preview { 159 | 160 | class Model: GraphObject { 161 | 162 | @GraphStored var count: Int = 0 163 | 164 | init() { 165 | 166 | } 167 | } 168 | 169 | struct ChildView: View { 170 | 171 | @Environment(Model.self) var model 172 | 173 | var body: some View { 174 | let _ = Self._printChanges() 175 | Text("\(model.count)") 176 | } 177 | } 178 | 179 | struct ParentView: View { 180 | 181 | @State var model: Model = .init() 182 | 183 | var body: some View { 184 | VStack { 185 | ChildView() 186 | .environment(model) 187 | 188 | Button("Increment") { 189 | model.count += 1 190 | } 191 | } 192 | } 193 | 194 | } 195 | 196 | return ParentView() 197 | 198 | } 199 | 200 | #endif 201 | -------------------------------------------------------------------------------- /Sources/StateGraph/Observation/Node+Observe.swift: -------------------------------------------------------------------------------- 1 | 2 | // MARK: - StateGraph Node Observation 3 | // 4 | // This file provides reactive observation capabilities for StateGraph nodes. 5 | // It includes: 6 | // - AsyncSequence-based observation with `observe()` 7 | // - Projected value tracking with `withGraphTrackingMap()` 8 | // - Group tracking with `withGraphTrackingGroup()` 9 | // - Value filtering with custom Filter implementations 10 | // 11 | // ## Quick Start Guide 12 | // 13 | // ### Basic Observation 14 | // ```swift 15 | // let node = Stored(wrappedValue: 0) 16 | // 17 | // // Method 1: Projected value tracking 18 | // let cancellable = withGraphTracking { 19 | // withGraphTrackingMap { 20 | // node.wrappedValue 21 | // } onChange: { value in 22 | // print("Changed to: \(value)") 23 | // } 24 | // } 25 | // 26 | // // Method 2: AsyncSequence-based 27 | // for try await value in node.observe() { 28 | // print("Value: \(value)") 29 | // } 30 | // ``` 31 | // 32 | // ### Advanced Patterns 33 | // ```swift 34 | // // Group tracking - reactive processing 35 | // withGraphTracking { 36 | // withGraphTrackingGroup { 37 | // if featureFlag.wrappedValue { 38 | // performExpensiveOperation(expensiveNode.wrappedValue) 39 | // } 40 | // updateUI(alwaysTrackedNode.wrappedValue) 41 | // } 42 | // } 43 | // 44 | // // Projected value tracking with custom filtering 45 | // withGraphTracking { 46 | // withGraphTrackingMap( 47 | // { node.wrappedValue }, 48 | // filter: MyCustomFilter() 49 | // ) { value in 50 | // print("Filtered value: \(value)") 51 | // } 52 | // } 53 | // ``` 54 | 55 | extension Node { 56 | 57 | /** 58 | Creates an async sequence that emits the node's value whenever it changes. 59 | 60 | This method provides an AsyncSequence-based API for observing node changes, which integrates 61 | well with Swift's async/await concurrency model. The sequence starts by emitting the current 62 | value, then emits subsequent values as the node changes. 63 | 64 | ## Basic Usage 65 | ```swift 66 | let node = Stored(wrappedValue: 0) 67 | 68 | for try await value in node.observe() { 69 | print("Value: \(value)") 70 | // Handle the value... 71 | } 72 | ``` 73 | 74 | ## With Async Processing 75 | ```swift 76 | Task { 77 | for try await value in node.observe() { 78 | await processValue(value) 79 | } 80 | } 81 | ``` 82 | 83 | ## Finite Processing 84 | ```swift 85 | let stream = node.observe() 86 | var iterator = stream.makeAsyncIterator() 87 | 88 | let initialValue = try await iterator.next() 89 | let nextValue = try await iterator.next() 90 | ``` 91 | 92 | - Returns: An async sequence that emits the node's value on changes 93 | - Note: The sequence starts with the current value, then emits subsequent changes 94 | - Note: The sequence continues indefinitely until cancelled or the node is deallocated 95 | */ 96 | public func observe() -> AsyncStream { 97 | withStateGraphTrackingStream { self.wrappedValue } 98 | } 99 | 100 | } 101 | 102 | extension AsyncSequence { 103 | /// Creates a new async sequence that starts by emitting the given value before the base sequence. 104 | func startWith(_ value: Element) -> AsyncStartWithSequence { 105 | return AsyncStartWithSequence(self, startWith: value) 106 | } 107 | } 108 | 109 | /** 110 | An async sequence that emits an initial value before proceeding with the base sequence. 111 | 112 | This is used internally by `Node.observe()` to ensure that the current value is emitted 113 | immediately, followed by subsequent changes from the base tracking stream. 114 | */ 115 | public struct AsyncStartWithSequence: AsyncSequence { 116 | 117 | public struct AsyncIterator: AsyncIteratorProtocol { 118 | public typealias Element = Base.Element 119 | 120 | private var base: Base.AsyncIterator 121 | private var first: Base.Element? 122 | 123 | init(_ value: Base.AsyncIterator, startWith: Base.Element) { 124 | self.base = value 125 | self.first = startWith 126 | } 127 | 128 | public mutating func next() async throws -> Base.Element? { 129 | if let first = first { 130 | self.first = nil 131 | return first 132 | } 133 | return try await base.next() 134 | } 135 | } 136 | 137 | public typealias Element = Base.Element 138 | 139 | let base: Base 140 | let startWith: Base.Element 141 | 142 | init(_ base: Base, startWith: Base.Element) { 143 | self.base = base 144 | self.startWith = startWith 145 | } 146 | 147 | public func makeAsyncIterator() -> AsyncIterator { 148 | return AsyncIterator(base.makeAsyncIterator(), startWith: startWith) 149 | } 150 | } 151 | 152 | extension AsyncStartWithSequence: Sendable where Base.Element: Sendable, Base: Sendable {} 153 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/NodeObserveTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import StateGraph 3 | 4 | @Suite("Stored.observe()") 5 | struct NodeObserveTests { 6 | 7 | @Test 8 | func basic() async throws { 9 | let node = Stored(wrappedValue: 0) 10 | let stream = node.observe() 11 | 12 | await confirmation(expectedCount: 2) { c in 13 | Task { 14 | for try await _ in stream { 15 | c.confirm() 16 | } 17 | } 18 | node.wrappedValue = 1 19 | 20 | try? await Task.sleep(for: .milliseconds(100)) 21 | 22 | } 23 | 24 | } 25 | 26 | @Test("Can observe value changes") 27 | func testObserveValueChanges() async throws { 28 | let node = Stored(wrappedValue: 0) 29 | let stream = node.observe() 30 | 31 | let results = OSAllocatedUnfairLock.init(initialState: [Int]()) 32 | let task = Task { 33 | for try await value in stream { 34 | print(value) 35 | results.withLock { $0.append(value) } 36 | } 37 | } 38 | 39 | try await Task.sleep(for: .milliseconds(100)) 40 | 41 | // Initial value is included 42 | #expect(results.withLock { $0 == [0] }) 43 | 44 | 45 | // Change value 46 | node.wrappedValue = 1 47 | try await Task.sleep(for: .milliseconds(100)) 48 | #expect(results.withLock { $0 == [0, 1] }) 49 | 50 | // Change value again 51 | node.wrappedValue = 2 52 | try await Task.sleep(for: .milliseconds(100)) 53 | #expect(results.withLock { $0 == [0, 1, 2] }) 54 | 55 | // Even with the same value, change notification is sent 56 | node.wrappedValue = 2 57 | try await Task.sleep(for: .milliseconds(100)) 58 | #expect(results.withLock { $0 == [0, 1, 2, 2] }) 59 | 60 | task.cancel() 61 | } 62 | 63 | @Test("Can observe complex type value changes") 64 | func testObserveWithComplexType() async throws { 65 | struct TestStruct: Equatable { 66 | var value: Int 67 | } 68 | 69 | let node = Stored(wrappedValue: TestStruct(value: 0)) 70 | let stream = node.observe() 71 | 72 | let results = OSAllocatedUnfairLock.init(initialState: [TestStruct]()) 73 | let task = Task { 74 | for try await value in stream { 75 | results.withLock { $0.append(value) } 76 | } 77 | } 78 | try await Task.sleep(for: .milliseconds(100)) 79 | 80 | // Initial value is included 81 | #expect(results.withLock { $0 == [TestStruct(value: 0)] }) 82 | 83 | // Change value 84 | node.wrappedValue = TestStruct(value: 1) 85 | try await Task.sleep(for: .milliseconds(100)) 86 | #expect(results.withLock { $0 == [TestStruct(value: 0), TestStruct(value: 1)] }) 87 | 88 | // Change value again 89 | node.wrappedValue = TestStruct(value: 2) 90 | try await Task.sleep(for: .milliseconds(100)) 91 | #expect(results.withLock { $0 == [TestStruct(value: 0), TestStruct(value: 1), TestStruct(value: 2)] }) 92 | 93 | // Even with the same value, change notification is sent 94 | node.wrappedValue = TestStruct(value: 2) 95 | try await Task.sleep(for: .milliseconds(100)) 96 | 97 | #expect(results.withLock { $0 == [TestStruct(value: 0), TestStruct(value: 1), TestStruct(value: 2), TestStruct(value: 2)] }) 98 | 99 | task.cancel() 100 | } 101 | 102 | @Test("Multiple subscribers can exist simultaneously") 103 | func testObserveWithMultipleSubscribers() async throws { 104 | let node = Stored(wrappedValue: 0) 105 | 106 | let results1 = OSAllocatedUnfairLock.init(initialState: [Int]()) 107 | let results2 = OSAllocatedUnfairLock.init(initialState: [Int]()) 108 | 109 | let task1 = Task { 110 | for try await value in node.observe() { 111 | results1.withLock { $0.append(value) } 112 | } 113 | } 114 | 115 | let task2 = Task { 116 | for try await value in node.observe() { 117 | results2.withLock { $0.append(value) } 118 | } 119 | } 120 | 121 | try await Task.sleep(for: .milliseconds(100)) 122 | 123 | // Initial value is included 124 | #expect(results1.withLock { $0 == [0] }) 125 | #expect(results2.withLock { $0 == [0] }) 126 | 127 | // Change value 128 | node.wrappedValue = 1 129 | try await Task.sleep(for: .milliseconds(100)) 130 | #expect(results1.withLock { $0 == [0, 1] }) 131 | #expect(results2.withLock { $0 == [0, 1] }) 132 | 133 | // Change value again 134 | node.wrappedValue = 2 135 | try await Task.sleep(for: .milliseconds(100)) 136 | #expect(results1.withLock { $0 == [0, 1, 2] }) 137 | #expect(results2.withLock { $0 == [0, 1, 2] }) 138 | 139 | // Even with the same value, change notification is sent 140 | node.wrappedValue = 2 141 | try await Task.sleep(for: .milliseconds(100)) 142 | #expect(results1.withLock { $0 == [0, 1, 2, 2] }) 143 | #expect(results2.withLock { $0 == [0, 1, 2, 2] }) 144 | 145 | task1.cancel() 146 | task2.cancel() 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/swift-state-graph.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 18 | 19 | 25 | 31 | 32 | 33 | 34 | 35 | 40 | 41 | 44 | 45 | 46 | 47 | 49 | 55 | 56 | 57 | 59 | 65 | 66 | 67 | 69 | 75 | 76 | 77 | 78 | 79 | 89 | 90 | 94 | 95 | 99 | 100 | 101 | 102 | 108 | 109 | 115 | 116 | 117 | 118 | 120 | 121 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /Sources/StateGraph/Documentation.docc/Quick-Start-Guide.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | Get started with Swift State Graph in minutes. 4 | 5 | ## Overview 6 | 7 | This guide will walk you through the basics of Swift State Graph, from installation to creating your first reactive model. By the end, you'll understand how to use stored and computed properties to build reactive applications. 8 | 9 | ## Installation 10 | 11 | Add Swift State Graph to your project using Swift Package Manager: 12 | 13 | ```swift 14 | dependencies: [ 15 | .package(url: "https://github.com/VergeGroup/swift-state-graph.git", from: "0.1.0") 16 | ] 17 | ``` 18 | 19 | Then add the dependency to your target: 20 | 21 | ```swift 22 | .target( 23 | name: "YourTarget", 24 | dependencies: ["StateGraph"] 25 | ) 26 | ``` 27 | 28 | ## Your First Reactive Model 29 | 30 | Let's create a simple counter that automatically tracks whether the count is even or odd: 31 | 32 | ```swift 33 | import StateGraph 34 | 35 | final class CounterViewModel { 36 | @GraphStored 37 | var count: Int = 0 38 | 39 | @GraphComputed 40 | var isEven: Bool 41 | 42 | @GraphComputed 43 | var displayText: String 44 | 45 | init() { 46 | // Define how isEven is computed 47 | self.$isEven = .init { [$count] _ in 48 | $count.wrappedValue % 2 == 0 49 | } 50 | 51 | // Define how displayText is computed 52 | self.$displayText = .init { [$count, $isEven] _ in 53 | let number = $count.wrappedValue 54 | let parity = $isEven.wrappedValue ? "even" : "odd" 55 | return "Count: \(number) (\(parity))" 56 | } 57 | } 58 | 59 | func increment() { 60 | count += 1 61 | // isEven and displayText automatically update! 62 | } 63 | 64 | func decrement() { 65 | count -= 1 66 | // isEven and displayText automatically update! 67 | } 68 | } 69 | ``` 70 | 71 | ## Understanding the Magic 72 | 73 | When you change `count`, here's what happens automatically: 74 | 75 | 1. **Dependency Detection**: Swift State Graph knows that `isEven` depends on `count` 76 | 2. **Cascade Updates**: When `count` changes, `isEven` is marked for recalculation 77 | 3. **Efficient Computation**: `displayText` depends on both `count` and `isEven`, so it updates too 78 | 4. **Lazy Evaluation**: Values are only recalculated when actually accessed 79 | 80 | ## Using with SwiftUI 81 | 82 | Swift State Graph integrates seamlessly with SwiftUI: 83 | 84 | ```swift 85 | import SwiftUI 86 | import StateGraph 87 | 88 | struct CounterView: View { 89 | @State private var viewModel = CounterViewModel() 90 | 91 | var body: some View { 92 | VStack(spacing: 20) { 93 | Text(viewModel.displayText) 94 | .font(.title) 95 | 96 | HStack { 97 | Button("−", action: viewModel.decrement) 98 | Button("+", action: viewModel.increment) 99 | } 100 | .buttonStyle(.borderedProminent) 101 | 102 | // Direct binding support 103 | Stepper("Count", value: viewModel.$count.binding, in: 0...100) 104 | } 105 | .padding() 106 | } 107 | } 108 | ``` 109 | 110 | ## Key Benefits You Just Experienced 111 | 112 | - **No Manual Updates**: You never called `updateDisplayText()` - it just works 113 | - **Type Safety**: Compile-time guarantees about your dependencies 114 | - **Performance**: Only recomputes what's necessary, when it's needed 115 | - **Declarative**: Your computed properties clearly express what they depend on 116 | 117 | ## Bonus: Persistent Storage 118 | 119 | Want your state to persist across app launches? Use backing storage: 120 | 121 | ```swift 122 | final class SettingsViewModel { 123 | // This value persists to UserDefaults automatically 124 | @GraphStored(backed: .userDefaults(key: "theme")) 125 | var theme: String = "light" 126 | 127 | @GraphComputed 128 | var isDarkMode: Bool 129 | 130 | init() { 131 | self.$isDarkMode = .init { [$theme] _ in 132 | $theme.wrappedValue == "dark" 133 | } 134 | } 135 | } 136 | ``` 137 | 138 | Changes to `theme` are automatically saved to UserDefaults and restored when your app launches! 139 | 140 | ## Next Steps 141 | 142 | Now that you've seen the basics, explore: 143 | 144 | - - Understand stored vs computed nodes in depth 145 | - - Learn about persistent storage options 146 | - - Build more complex reactive models 147 | - - Learn advanced SwiftUI patterns 148 | - - Migrate from `@Observable` if you're already using it 149 | 150 | ## Common First Questions 151 | 152 | ### Q: How is this different from `@Observable`? 153 | **A:** Swift State Graph adds computed properties with automatic dependency tracking. With `@Observable`, you'd need to manually call update methods. 154 | 155 | ### Q: What about performance? 156 | **A:** Swift State Graph is designed for performance - it only recalculates what's needed, when it's needed, using intelligent caching. 157 | 158 | ### Q: Can I use this with UIKit? 159 | **A:** Absolutely! Use `withGraphTracking` to observe changes. See for details. -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/swift-state-graph-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 18 | 19 | 25 | 31 | 32 | 33 | 39 | 45 | 46 | 47 | 48 | 49 | 55 | 56 | 58 | 64 | 65 | 66 | 68 | 74 | 75 | 76 | 78 | 84 | 85 | 86 | 87 | 88 | 98 | 99 | 105 | 106 | 112 | 113 | 114 | 115 | 117 | 118 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/DeadlockTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import StateGraph 3 | import Foundation 4 | 5 | @Suite("Deadlock Tests") 6 | struct DeadlockTests { 7 | 8 | final class NotificationCounter: @unchecked Sendable { 9 | private let lock = NSLock() 10 | private var _count = 0 11 | 12 | var count: Int { 13 | lock.lock() 14 | defer { lock.unlock() } 15 | return _count 16 | } 17 | 18 | func increment() { 19 | lock.lock() 20 | defer { lock.unlock() } 21 | _count += 1 22 | } 23 | 24 | func reset() { 25 | lock.lock() 26 | defer { lock.unlock() } 27 | _count = 0 28 | } 29 | } 30 | 31 | struct State { 32 | 33 | var value: Int = 0 34 | 35 | mutating func run() async { 36 | try! await Task.sleep(nanoseconds: 100_000_000) // 100ms 37 | } 38 | 39 | mutating func updateValueDuringAsync() async { 40 | value = 1 41 | try! await Task.sleep(nanoseconds: 50_000_000) // 50ms 42 | value = 2 43 | try! await Task.sleep(nanoseconds: 50_000_000) // 50ms 44 | value = 3 45 | } 46 | } 47 | 48 | @Test 49 | func deadlockDetectionWithMultipleObjects() async { 50 | 51 | let node = Stored.init(wrappedValue: .init()) 52 | 53 | // Test that direct property access with async operations doesn't deadlock 54 | // This would have caused deadlock with the old _modify implementation 55 | // https://github.com/VergeGroup/swift-state-graph/pull/56 56 | await node.wrappedValue.run() 57 | try! await Task.sleep(nanoseconds: 20_000_000) // 20ms between sends 58 | 59 | print(node.wrappedValue.value) 60 | } 61 | 62 | @Test 63 | func notificationCountDuringMutatingAsync() async { 64 | let node = Stored.init(wrappedValue: .init()) 65 | 66 | let counter = NotificationCounter() 67 | let computed = Computed { _ in 68 | counter.increment() 69 | return node.wrappedValue.value 70 | } 71 | 72 | // Initial access to establish dependency 73 | _ = computed.wrappedValue 74 | #expect(counter.count == 1) 75 | 76 | // Reset counter 77 | counter.reset() 78 | 79 | // Test: Direct property assignment pattern (getter + setter) 80 | print("=== Testing getter/setter pattern ===") 81 | await node.wrappedValue.updateValueDuringAsync() // mutating async operation 82 | 83 | // Check how many notifications were triggered 84 | _ = computed.wrappedValue // Force recomputation to get final count 85 | print("Notifications after getter/setter pattern: \(counter.count)") 86 | #expect(counter.count == 1) // Should be 1 notification from setter 87 | #expect(node.wrappedValue.value == 3) 88 | } 89 | 90 | @Test 91 | func notificationCountWithMultipleDirectAssignments() async { 92 | let node = Stored.init(wrappedValue: .init()) 93 | 94 | let counter = NotificationCounter() 95 | let computed = Computed { _ in 96 | counter.increment() 97 | return node.wrappedValue.value 98 | } 99 | 100 | // Initial access 101 | _ = computed.wrappedValue 102 | #expect(counter.count == 1) 103 | counter.reset() 104 | 105 | print("=== Testing multiple direct assignments ===") 106 | 107 | // Multiple direct assignments 108 | print("Before first assignment, counter: \(counter.count)") 109 | node.wrappedValue = State(value: 1) 110 | print("After first assignment, counter: \(counter.count)") 111 | 112 | node.wrappedValue = State(value: 2) 113 | print("After second assignment, counter: \(counter.count)") 114 | 115 | node.wrappedValue = State(value: 3) 116 | print("After third assignment, counter: \(counter.count)") 117 | 118 | // Check notifications 119 | print("Before final computed access, counter: \(counter.count)") 120 | _ = computed.wrappedValue 121 | print("Notifications after 3 direct assignments: \(counter.count)") 122 | 123 | // Update expectation based on actual behavior 124 | print("Actual behavior: Only 1 notification triggered when computed value is accessed") 125 | #expect(counter.count == 1) // Computed is lazily evaluated only once 126 | #expect(node.wrappedValue.value == 3) 127 | } 128 | 129 | @Test 130 | func immediateNotificationBehavior() async { 131 | let node = Stored.init(wrappedValue: .init()) 132 | 133 | let counter = NotificationCounter() 134 | let computed = Computed { _ in 135 | counter.increment() 136 | return node.wrappedValue.value 137 | } 138 | 139 | // Establish dependency 140 | _ = computed.wrappedValue 141 | counter.reset() 142 | 143 | print("=== Testing immediate notification behavior ===") 144 | 145 | // Assignment followed by immediate access 146 | print("Counter before assignment: \(counter.count)") 147 | node.wrappedValue = State(value: 1) 148 | print("Counter after assignment, before access: \(counter.count)") 149 | _ = computed.wrappedValue 150 | print("Counter after computed access: \(counter.count)") 151 | 152 | counter.reset() 153 | 154 | // Multiple assignments with intermittent access 155 | node.wrappedValue = State(value: 2) 156 | _ = computed.wrappedValue // Access 1 157 | print("After assignment & access 1: \(counter.count)") 158 | 159 | node.wrappedValue = State(value: 3) 160 | _ = computed.wrappedValue // Access 2 161 | print("After assignment & access 2: \(counter.count)") 162 | 163 | #expect(counter.count == 2) // Two separate computations 164 | #expect(node.wrappedValue.value == 3) 165 | } 166 | 167 | } 168 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/GraphTrackingsTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import StateGraph 5 | 6 | @Suite("GraphTrackings Tests") 7 | struct GraphTrackingsTests { 8 | 9 | final class ValueCollector: @unchecked Sendable { 10 | private let lock = NSLock() 11 | private var _values: [T] = [] 12 | 13 | var values: [T] { 14 | lock.lock() 15 | defer { lock.unlock() } 16 | return _values 17 | } 18 | 19 | var count: Int { 20 | lock.lock() 21 | defer { lock.unlock() } 22 | return _values.count 23 | } 24 | 25 | func append(_ value: T) { 26 | lock.lock() 27 | defer { lock.unlock() } 28 | _values.append(value) 29 | } 30 | } 31 | 32 | @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) 33 | @Test 34 | func basicAsyncSequence() async throws { 35 | let firstName = Stored(wrappedValue: "John") 36 | let lastName = Stored(wrappedValue: "Doe") 37 | let collector = ValueCollector() 38 | 39 | let task = Task { 40 | for await fullName in GraphTrackings({ 41 | "\(firstName.wrappedValue) \(lastName.wrappedValue)" 42 | }) { 43 | collector.append(fullName) 44 | if collector.count >= 3 { 45 | break 46 | } 47 | } 48 | } 49 | 50 | try await Task.sleep(nanoseconds: 50_000_000) 51 | 52 | firstName.wrappedValue = "Jane" 53 | try await Task.sleep(nanoseconds: 100_000_000) 54 | 55 | lastName.wrappedValue = "Smith" 56 | try await Task.sleep(nanoseconds: 100_000_000) 57 | 58 | try await task.value 59 | 60 | #expect(collector.values == ["John Doe", "Jane Doe", "Jane Smith"]) 61 | } 62 | 63 | @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) 64 | @Test 65 | func startWithBehavior() async throws { 66 | let value = Stored(wrappedValue: 42) 67 | let collector = ValueCollector() 68 | 69 | let task = Task { 70 | for await v in GraphTrackings({ value.wrappedValue }) { 71 | collector.append(v) 72 | break 73 | } 74 | } 75 | 76 | try await task.value 77 | 78 | #expect(collector.values == [42]) 79 | } 80 | 81 | @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) 82 | @Test 83 | func dynamicTracking() async throws { 84 | let useA = Stored(wrappedValue: true) 85 | let valueA = Stored(wrappedValue: "A") 86 | let valueB = Stored(wrappedValue: "B") 87 | let collector = ValueCollector() 88 | 89 | let task = Task { 90 | for await v in GraphTrackings({ 91 | useA.wrappedValue ? valueA.wrappedValue : valueB.wrappedValue 92 | }) { 93 | collector.append(v) 94 | if collector.count >= 3 { 95 | break 96 | } 97 | } 98 | } 99 | 100 | try await Task.sleep(nanoseconds: 50_000_000) 101 | 102 | // Change valueB (not tracked when useA is true) 103 | valueB.wrappedValue = "B2" 104 | try await Task.sleep(nanoseconds: 100_000_000) 105 | 106 | // Switch to B 107 | useA.wrappedValue = false 108 | try await Task.sleep(nanoseconds: 100_000_000) 109 | 110 | // Now valueB is tracked 111 | valueB.wrappedValue = "B3" 112 | try await Task.sleep(nanoseconds: 100_000_000) 113 | 114 | try await task.value 115 | 116 | #expect(collector.values == ["A", "B2", "B3"]) 117 | } 118 | 119 | @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) 120 | @Test 121 | func taskCancellation() async throws { 122 | let value = Stored(wrappedValue: 0) 123 | let collector = ValueCollector() 124 | 125 | let task = Task { 126 | for await v in GraphTrackings({ value.wrappedValue }) { 127 | collector.append(v) 128 | } 129 | } 130 | 131 | try await Task.sleep(nanoseconds: 50_000_000) 132 | 133 | value.wrappedValue = 1 134 | try await Task.sleep(nanoseconds: 100_000_000) 135 | 136 | task.cancel() 137 | try await Task.sleep(nanoseconds: 50_000_000) 138 | 139 | value.wrappedValue = 2 140 | try await Task.sleep(nanoseconds: 100_000_000) 141 | 142 | #expect(collector.count <= 2) 143 | } 144 | 145 | @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) 146 | @Test 147 | func untilFinished() async throws { 148 | let iterationCounter = Stored(wrappedValue: 0) 149 | let value = Stored(wrappedValue: 0) 150 | let collector = ValueCollector() 151 | 152 | let task = Task { 153 | for await v in GraphTrackings.untilFinished({ 154 | iterationCounter.wrappedValue += 1 155 | if iterationCounter.wrappedValue > 3 { 156 | return .finish 157 | } 158 | return .next(value.wrappedValue) 159 | }) { 160 | collector.append(v) 161 | } 162 | } 163 | 164 | try await Task.sleep(nanoseconds: 50_000_000) 165 | 166 | value.wrappedValue = 10 167 | try await Task.sleep(nanoseconds: 100_000_000) 168 | 169 | value.wrappedValue = 20 170 | try await Task.sleep(nanoseconds: 100_000_000) 171 | 172 | value.wrappedValue = 30 173 | try await Task.sleep(nanoseconds: 100_000_000) 174 | 175 | try await task.value 176 | 177 | #expect(collector.values == [0, 10, 20]) 178 | } 179 | 180 | @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) 181 | @Test 182 | func multipleNodes() async throws { 183 | let x = Stored(wrappedValue: 1) 184 | let y = Stored(wrappedValue: 2) 185 | let z = Stored(wrappedValue: 3) 186 | let collector = ValueCollector() 187 | 188 | let task = Task { 189 | for await sum in GraphTrackings({ x.wrappedValue + y.wrappedValue }) { 190 | collector.append(sum) 191 | if collector.count >= 3 { 192 | break 193 | } 194 | } 195 | } 196 | 197 | try await Task.sleep(nanoseconds: 50_000_000) 198 | 199 | x.wrappedValue = 5 200 | try await Task.sleep(nanoseconds: 100_000_000) 201 | 202 | // Change z (not tracked) 203 | z.wrappedValue = 100 204 | try await Task.sleep(nanoseconds: 50_000_000) 205 | 206 | y.wrappedValue = 10 207 | try await Task.sleep(nanoseconds: 100_000_000) 208 | 209 | try await task.value 210 | 211 | #expect(collector.values == [3, 7, 15]) 212 | } 213 | 214 | 215 | } 216 | 217 | import Observation 218 | 219 | @available(macOS 26, iOS 26, tvOS 26, watchOS 26, *) 220 | struct Syntax { 221 | 222 | 223 | func basic() { 224 | 225 | let s = Observations { 226 | 227 | } 228 | 229 | Task { 230 | for await e in s { 231 | 232 | } 233 | } 234 | 235 | } 236 | 237 | } 238 | -------------------------------------------------------------------------------- /Tests/StateGraphNormalizationTests/NormalizationTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import StateGraphNormalization 3 | import StateGraph 4 | import Foundation 5 | 6 | extension ComputedEnvironmentValues { 7 | 8 | var normalizedStore: NormalizedStore! { 9 | get { 10 | self[NormalizedStore.self] 11 | } 12 | set { 13 | self[NormalizedStore.self] = newValue 14 | } 15 | } 16 | } 17 | 18 | final class User: TypedIdentifiable, Sendable { 19 | 20 | typealias TypedIdentifierRawValue = String 21 | 22 | let typedID: TypedID 23 | 24 | @GraphStored 25 | var name: String 26 | 27 | @GraphStored 28 | var age: Int 29 | 30 | @GraphComputed 31 | var posts: [Post] 32 | 33 | init( 34 | id: String, 35 | name: String, 36 | age: Int 37 | ) { 38 | self.typedID = .init(id) 39 | self.name = name 40 | self.age = age 41 | self.$posts = .init(name: "posts") { context in 42 | context.environment.normalizedStore.posts 43 | .filter { $0.author.id.raw == id } 44 | .sorted(by: { $0.createdAt < $1.createdAt }) 45 | } 46 | } 47 | } 48 | 49 | final class Post: TypedIdentifiable, Hashable, Sendable { 50 | 51 | static func == (lhs: Post, rhs: Post) -> Bool { 52 | lhs === rhs 53 | } 54 | 55 | func hash(into hasher: inout Hasher) { 56 | hasher.combine(ObjectIdentifier(self)) 57 | } 58 | 59 | typealias TypedIdentifierRawValue = String 60 | 61 | let typedID: TypedID 62 | 63 | @GraphStored 64 | var title: String 65 | 66 | @GraphStored 67 | var content: String 68 | 69 | let author: User 70 | 71 | let createdAt: Date = .init() 72 | 73 | @GraphComputed 74 | var allComments: [Comment] 75 | 76 | @GraphComputed 77 | var activeComments: [Comment] 78 | 79 | init( 80 | id: String, 81 | title: String, 82 | content: String, 83 | author: User 84 | ) { 85 | self.typedID = .init(id) 86 | self.title = title 87 | self.content = content 88 | self.author = author 89 | self.$allComments = .init(name: "allComments") { context in 90 | context.environment.normalizedStore.comments 91 | .filter { $0.post.id.raw == id } 92 | .sorted(by: { $0.createdAt < $1.createdAt }) 93 | } 94 | self.$activeComments = .init(name: "activeComments") { context in 95 | context.environment.normalizedStore.comments 96 | .filter { $0.post.id.raw == id } 97 | .filter { !$0.isDeleted } 98 | .sorted(by: { $0.createdAt < $1.createdAt }) 99 | } 100 | } 101 | } 102 | 103 | final class Comment: TypedIdentifiable, Sendable { 104 | typealias TypedIdentifierRawValue = String 105 | 106 | let typedID: TypedID 107 | 108 | @GraphStored 109 | var text: String 110 | 111 | @GraphStored 112 | var createdAt: Date = .init() 113 | 114 | let post: Post 115 | 116 | let author: User 117 | 118 | @GraphStored 119 | var isDeleted: Bool = false 120 | 121 | init(id: String, text: String, post: Post, author: User) { 122 | self.typedID = .init(id) 123 | self.text = text 124 | self.post = post 125 | self.author = author 126 | } 127 | } 128 | 129 | // Normalized store using StateGraph 130 | final class NormalizedStore: ComputedEnvironmentKey, Sendable { 131 | 132 | typealias Value = NormalizedStore 133 | 134 | @GraphStored 135 | var users: EntityStore = .init() 136 | @GraphStored 137 | var posts: EntityStore = .init() 138 | @GraphStored 139 | var comments: EntityStore = .init() 140 | 141 | 142 | } 143 | 144 | @Suite 145 | struct NormalizationTests { 146 | 147 | @MainActor 148 | @Test func basic() async { 149 | 150 | let store = NormalizedStore() 151 | 152 | StateGraphGlobal.computedEnvironmentValues.withLock { values in 153 | values.normalizedStore = store 154 | } 155 | 156 | let user = User.init( 157 | id: "user1", 158 | name: "John Doe", 159 | age: 30 160 | ) 161 | 162 | store.users.add(user) 163 | 164 | let post = Post.init( 165 | id: UUID().uuidString, 166 | title: "My first post", 167 | content: "This is my first post", 168 | author: user 169 | ) 170 | 171 | store.posts.add(post) 172 | 173 | #expect(user.posts.count == 1) 174 | 175 | print(await NodeStore.shared.graphViz()) 176 | 177 | } 178 | 179 | @MainActor 180 | @Test func randomDataGeneration() async { 181 | 182 | let task = Task { 183 | let store = NormalizedStore() 184 | 185 | StateGraphGlobal.computedEnvironmentValues.withLock { values in 186 | values.normalizedStore = store 187 | } 188 | 189 | // ランダムなユーザーを生成 190 | let users = (0..<5).map { i in 191 | User( 192 | id: "user\(i)", 193 | name: "User \(i)", 194 | age: Int.random(in: 18...80) 195 | ) 196 | } 197 | 198 | // ユーザーをストアに追加 199 | users.forEach { store.users.add($0) } 200 | 201 | // 各ユーザーに対してランダムな投稿を生成 202 | let posts = users.flatMap { user in 203 | (0.. [DeclSyntax] { 29 | 30 | guard let variableDecl = declaration.as(VariableDeclSyntax.self) else { 31 | return [] 32 | } 33 | 34 | guard variableDecl.typeSyntax != nil else { 35 | context.addDiagnostics(from: Error.needsTypeAnnotation, node: declaration) 36 | return [] 37 | } 38 | 39 | // check the current limitation 40 | do { 41 | if variableDecl.didSetBlock != nil { 42 | context.addDiagnostics(from: Error.didSetNotSupported, node: declaration) 43 | return [] 44 | } 45 | 46 | if variableDecl.willSetBlock != nil { 47 | context.addDiagnostics(from: Error.willSetNotSupported, node: declaration) 48 | return [] 49 | } 50 | } 51 | 52 | var newMembers: [DeclSyntax] = [] 53 | 54 | let ignoreMacroAttached = variableDecl.attributes.contains { 55 | switch $0 { 56 | case .attribute(let attribute): 57 | return attribute.attributeName.description == "Ignored" 58 | case .ifConfigDecl: 59 | return false 60 | } 61 | } 62 | 63 | guard !ignoreMacroAttached else { 64 | return [] 65 | } 66 | 67 | for binding in variableDecl.bindings { 68 | if binding.accessorBlock != nil { 69 | // skip computed properties 70 | continue 71 | } 72 | } 73 | 74 | let prefix = "$" 75 | 76 | var _variableDecl = variableDecl 77 | .trimmed 78 | .makeConstant() 79 | .inheritAccessControl(with: variableDecl) 80 | 81 | _variableDecl.attributes = [.init(.init(stringLiteral: "@GraphIgnored"))] 82 | 83 | _variableDecl = 84 | _variableDecl 85 | .renamingIdentifier(with: prefix) 86 | .modifyingTypeAnnotation({ type in 87 | return "Computed<\(type.trimmed)>" 88 | }) 89 | 90 | let name = variableDecl.name 91 | 92 | if variableDecl.isOptional && variableDecl.hasInitializer == false { 93 | 94 | } else { 95 | _variableDecl = _variableDecl.modifyingInit({ initializer in 96 | 97 | if variableDecl.isWeak { 98 | return .init( 99 | value: #".init(name: "\#(raw: name)", wrappedValue: .init(\#(initializer.trimmed.value)))"# as ExprSyntax) 100 | } else if variableDecl.isUnowned { 101 | return .init( 102 | value: #".init(name: "\#(raw: name)", wrappedValue: .init(\#(initializer.trimmed.value)))"# as ExprSyntax) 103 | } else { 104 | return .init(value: #".init(name: "\#(raw: name)", wrappedValue: \#(initializer.trimmed.value))"# as ExprSyntax) 105 | } 106 | 107 | }) 108 | } 109 | 110 | do { 111 | 112 | // remove accessors 113 | _variableDecl = _variableDecl.with( 114 | \.bindings, 115 | .init( 116 | _variableDecl.bindings.map { binding in 117 | binding.with(\.accessorBlock, nil) 118 | } 119 | ) 120 | ) 121 | 122 | } 123 | 124 | newMembers.append(DeclSyntax(_variableDecl)) 125 | 126 | return newMembers 127 | } 128 | } 129 | 130 | extension ComputedMacro: AccessorMacro { 131 | 132 | public static func expansion( 133 | of node: SwiftSyntax.AttributeSyntax, 134 | providingAccessorsOf declaration: some SwiftSyntax.DeclSyntaxProtocol, 135 | in context: some SwiftSyntaxMacros.MacroExpansionContext 136 | ) throws -> [SwiftSyntax.AccessorDeclSyntax] { 137 | 138 | guard let variableDecl = declaration.as(VariableDeclSyntax.self) else { 139 | return [] 140 | } 141 | 142 | guard let binding = variableDecl.bindings.first, 143 | let identifierPattern = binding.pattern.as(IdentifierPatternSyntax.self) 144 | else { 145 | return [] 146 | } 147 | 148 | guard binding.initializer == nil else { 149 | context.addDiagnostics(from: Error.cannotHaveInitializer, node: declaration) 150 | return [] 151 | } 152 | 153 | let propertyName = identifierPattern.identifier.text 154 | 155 | guard variableDecl.isComputed == false else { 156 | context.addDiagnostics(from: Error.computedVariableIsNotSupported, node: declaration) 157 | return [] 158 | } 159 | 160 | guard variableDecl.isConstant == false else { 161 | context.addDiagnostics(from: Error.constantVariableIsNotSupported, node: declaration) 162 | return [] 163 | } 164 | 165 | guard variableDecl.isConstant == false else { 166 | fatalError() 167 | } 168 | 169 | guard !variableDecl.isWeak else { 170 | context.addDiagnostics(from: Error.weakVariableNotSupported, node: declaration) 171 | return [] 172 | } 173 | 174 | guard !variableDecl.isUnowned else { 175 | context.addDiagnostics(from: Error.unownedVariableNotSupported, node: declaration) 176 | return [] 177 | } 178 | 179 | let readAccessor = AccessorDeclSyntax( 180 | """ 181 | get { 182 | return $\(raw: propertyName).wrappedValue 183 | } 184 | """ 185 | ) 186 | 187 | var accessors: [AccessorDeclSyntax] = [] 188 | 189 | accessors.append(readAccessor) 190 | 191 | return accessors 192 | } 193 | 194 | } 195 | 196 | // MARK: - Diagnostic Messages 197 | 198 | extension ComputedMacro.Error: DiagnosticMessage { 199 | public var message: String { 200 | switch self { 201 | case .constantVariableIsNotSupported: 202 | return "Constant variables are not supported with @GraphComputed" 203 | case .computedVariableIsNotSupported: 204 | return "Computed variables are not supported with @GraphComputed" 205 | case .needsTypeAnnotation: 206 | return "@GraphComputed requires explicit type annotation" 207 | case .cannotHaveInitializer: 208 | return "@GraphComputed cannot have an initializer" 209 | case .didSetNotSupported: 210 | return "didSet is not supported with @GraphComputed" 211 | case .willSetNotSupported: 212 | return "willSet is not supported with @GraphComputed" 213 | case .weakVariableNotSupported: 214 | return "weak variables are not supported with @GraphComputed" 215 | case .unownedVariableNotSupported: 216 | return "unowned variables are not supported with @GraphComputed" 217 | } 218 | } 219 | 220 | public var diagnosticID: MessageID { 221 | MessageID(domain: "ComputedMacro", id: "\(self)") 222 | } 223 | 224 | public var severity: DiagnosticSeverity { 225 | return .error 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/UserDefaultsStoredTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | @testable import StateGraph 4 | 5 | @Suite 6 | struct UserDefaultsStoredTests { 7 | 8 | // テスト用のユニークなキーを生成するヘルパー 9 | private func makeTestKey() -> String { 10 | return "test_key_\(UUID().uuidString)" 11 | } 12 | 13 | // テスト用のUserDefaultsスイートを作成するヘルパー 14 | private func makeTestUserDefaults() -> UserDefaults { 15 | let suiteName = "test_suite_\(UUID().uuidString)" 16 | return UserDefaults(suiteName: suiteName)! 17 | } 18 | 19 | // UserDefaultsStoredノードを作成するヘルパー 20 | private func makeUserDefaultsStoredNode( 21 | userDefaults: UserDefaults, 22 | key: String, 23 | defaultValue: T 24 | ) -> UserDefaultsStored { 25 | let storage = UserDefaultsStorage( 26 | userDefaults: userDefaults, 27 | key: key, 28 | defaultValue: defaultValue 29 | ) 30 | return _Stored( 31 | storage: storage 32 | ) 33 | } 34 | 35 | @Test 36 | func userDefaultsStored_basic_functionality() { 37 | let key = makeTestKey() 38 | let userDefaults = makeTestUserDefaults() 39 | 40 | let node = makeUserDefaultsStoredNode( 41 | userDefaults: userDefaults, 42 | key: key, 43 | defaultValue: "default" 44 | ) 45 | 46 | // 初期値のテスト 47 | #expect(node.wrappedValue == "default") 48 | 49 | // 値の設定と取得のテスト 50 | node.wrappedValue = "updated" 51 | #expect(node.wrappedValue == "updated") 52 | #expect(userDefaults.string(forKey: key) == "updated") 53 | } 54 | 55 | @Test 56 | func userDefaultsStored_with_suite() { 57 | let key = makeTestKey() 58 | let userDefaults = makeTestUserDefaults() 59 | 60 | let node = makeUserDefaultsStoredNode( 61 | userDefaults: userDefaults, 62 | key: key, 63 | defaultValue: 42 64 | ) 65 | 66 | // 初期値のテスト 67 | #expect(node.wrappedValue == 42) 68 | 69 | // 値の設定と取得のテスト 70 | node.wrappedValue = 100 71 | #expect(node.wrappedValue == 100) 72 | #expect(userDefaults.integer(forKey: key) == 100) 73 | } 74 | 75 | @Test 76 | func userDefaultsStored_external_updates_trigger_notifications() async { 77 | let key = makeTestKey() 78 | let userDefaults = makeTestUserDefaults() 79 | 80 | let node = makeUserDefaultsStoredNode( 81 | userDefaults: userDefaults, 82 | key: key, 83 | defaultValue: "initial" 84 | ) 85 | 86 | // 依存するComputedノードを作成 87 | let computedNode = Computed(name: "dependent") { _ in 88 | return "computed_\(node.wrappedValue)" 89 | } 90 | 91 | // 初期値の確認 92 | #expect(computedNode.wrappedValue == "computed_initial") 93 | 94 | await confirmation(expectedCount: 1) { confirmation in 95 | // グラフの変更を追跡 96 | withStateGraphTracking { 97 | _ = computedNode.wrappedValue 98 | } didChange: { 99 | #expect(computedNode.wrappedValue == "computed_external_update") 100 | confirmation.confirm() 101 | } 102 | 103 | // UserDefaultsを外部から直接更新 104 | userDefaults.set("external_update", forKey: key) 105 | 106 | try? await Task.sleep(for: .milliseconds(100)) 107 | } 108 | } 109 | 110 | @Test 111 | func userDefaultsStored_multiple_nodes_same_key() async { 112 | let key = makeTestKey() 113 | let userDefaults = makeTestUserDefaults() 114 | 115 | // 同じキーで複数のノードを作成 116 | let node1 = makeUserDefaultsStoredNode( 117 | userDefaults: userDefaults, 118 | key: key, 119 | defaultValue: "default" 120 | ) 121 | 122 | let node2 = makeUserDefaultsStoredNode( 123 | userDefaults: userDefaults, 124 | key: key, 125 | defaultValue: "default" 126 | ) 127 | 128 | // 初期値の確認 129 | #expect(node1.wrappedValue == "default") 130 | #expect(node2.wrappedValue == "default") 131 | 132 | await confirmation(expectedCount: 2) { confirmation in 133 | // 両方のノードの変更を追跡 134 | withStateGraphTracking { 135 | _ = node1.wrappedValue 136 | } didChange: { 137 | #expect(node1.wrappedValue == "shared_update") 138 | confirmation.confirm() 139 | } 140 | 141 | withStateGraphTracking { 142 | _ = node2.wrappedValue 143 | } didChange: { 144 | #expect(node2.wrappedValue == "shared_update") 145 | confirmation.confirm() 146 | } 147 | 148 | // UserDefaultsを外部から更新 149 | userDefaults.set("shared_update", forKey: key) 150 | NotificationCenter.default.post( 151 | name: UserDefaults.didChangeNotification, 152 | object: userDefaults 153 | ) 154 | 155 | try? await Task.sleep(for: .milliseconds(100)) 156 | } 157 | } 158 | 159 | @Test 160 | func userDefaultsStored_different_types() { 161 | let baseKey = makeTestKey() 162 | let userDefaults = makeTestUserDefaults() 163 | 164 | // 異なる型のテスト 165 | let stringNode = makeUserDefaultsStoredNode( 166 | userDefaults: userDefaults, 167 | key: "\(baseKey)_string", 168 | defaultValue: "default" 169 | ) 170 | 171 | let intNode = makeUserDefaultsStoredNode( 172 | userDefaults: userDefaults, 173 | key: "\(baseKey)_int", 174 | defaultValue: 0 175 | ) 176 | 177 | let boolNode = makeUserDefaultsStoredNode( 178 | userDefaults: userDefaults, 179 | key: "\(baseKey)_bool", 180 | defaultValue: false 181 | ) 182 | 183 | let doubleNode = makeUserDefaultsStoredNode( 184 | userDefaults: userDefaults, 185 | key: "\(baseKey)_double", 186 | defaultValue: 0.0 187 | ) 188 | 189 | // 初期値のテスト 190 | #expect(stringNode.wrappedValue == "default") 191 | #expect(intNode.wrappedValue == 0) 192 | #expect(boolNode.wrappedValue == false) 193 | #expect(doubleNode.wrappedValue == 0.0) 194 | 195 | // 値の設定と取得のテスト 196 | stringNode.wrappedValue = "test" 197 | intNode.wrappedValue = 42 198 | boolNode.wrappedValue = true 199 | doubleNode.wrappedValue = 3.14 200 | 201 | #expect(stringNode.wrappedValue == "test") 202 | #expect(intNode.wrappedValue == 42) 203 | #expect(boolNode.wrappedValue == true) 204 | #expect(doubleNode.wrappedValue == 3.14) 205 | 206 | // UserDefaultsに直接保存されているかの確認 207 | #expect(userDefaults.string(forKey: "\(baseKey)_string") == "test") 208 | #expect(userDefaults.integer(forKey: "\(baseKey)_int") == 42) 209 | #expect(userDefaults.bool(forKey: "\(baseKey)_bool") == true) 210 | #expect(userDefaults.double(forKey: "\(baseKey)_double") == 3.14) 211 | } 212 | 213 | @MainActor 214 | @Test 215 | func userDefaultsStored_cleanup_on_deinit() async { 216 | 217 | let key = makeTestKey() 218 | let userDefaults = makeTestUserDefaults() 219 | 220 | var node: UserDefaultsStored? = makeUserDefaultsStoredNode( 221 | userDefaults: userDefaults, 222 | key: key, 223 | defaultValue: "test" 224 | ) 225 | 226 | weak var weakNode = node 227 | 228 | #expect(node!.wrappedValue == "test") 229 | 230 | node = nil 231 | 232 | await Task.yield() 233 | 234 | if weakNode != nil { 235 | print("") 236 | } 237 | 238 | try? await Task.sleep(for: .milliseconds(100)) 239 | 240 | #expect(weakNode == nil) 241 | 242 | 243 | } 244 | 245 | 246 | } 247 | -------------------------------------------------------------------------------- /Sources/StateGraphMacro/Extension.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | 3 | extension VariableDeclSyntax { 4 | 5 | consuming func makeConstant() -> Self { 6 | self 7 | .with(\.bindingSpecifier, .keyword(.let)) 8 | .with(\.modifiers, []) 9 | } 10 | 11 | consuming func inheritAccessControl(with other: VariableDeclSyntax) -> Self { 12 | 13 | let accessControlKinds: Set = [ 14 | .keyword(.private), 15 | .keyword(.fileprivate), 16 | .keyword(.internal), 17 | .keyword(.public), 18 | .keyword(.open), 19 | ] 20 | 21 | var hasDetailModifier = false 22 | var primaryAccessLevel: DeclModifierSyntax? 23 | 24 | // Find the primary access level (e.g., 'public' in 'public private(set)') 25 | for modifier in other.modifiers { 26 | if accessControlKinds.contains(modifier.name.tokenKind) { 27 | if modifier.detail != nil { 28 | // This is something like private(set) 29 | hasDetailModifier = true 30 | // Use the main access level without the detail 31 | if primaryAccessLevel == nil { 32 | primaryAccessLevel = DeclModifierSyntax(name: modifier.name) 33 | } 34 | } else { 35 | // This is a regular access modifier like 'public' or 'internal' 36 | if primaryAccessLevel == nil || hasDetailModifier { 37 | primaryAccessLevel = modifier 38 | } 39 | } 40 | } 41 | } 42 | 43 | // Build the final modifiers list 44 | var finalModifiers = modifiers.filter { 45 | !accessControlKinds.contains($0.name.tokenKind) 46 | } 47 | 48 | if let primaryAccessLevel = primaryAccessLevel { 49 | finalModifiers.append(primaryAccessLevel) 50 | } 51 | 52 | return self.with(\.modifiers, finalModifiers) 53 | } 54 | 55 | consuming func makePrivate() -> Self { 56 | self.with( 57 | \.modifiers, 58 | [ 59 | .init(name: .keyword(.private)) 60 | ]) 61 | } 62 | 63 | var isWeak: Bool { 64 | self.modifiers.contains { 65 | $0.name.tokenKind == .keyword(.weak) 66 | } 67 | } 68 | 69 | var isUnowned: Bool { 70 | self.modifiers.contains { 71 | $0.name.tokenKind == .keyword(.unowned) 72 | } 73 | } 74 | 75 | var isStatic: Bool { 76 | self.modifiers.contains { 77 | $0.name.tokenKind == .keyword(.static) 78 | } 79 | } 80 | 81 | consuming func useModifier(sameAs: VariableDeclSyntax) -> Self { 82 | self.with(\.modifiers, sameAs.modifiers) 83 | } 84 | 85 | var hasInitializer: Bool { 86 | self.bindings.contains(where: { $0.initializer != nil }) 87 | } 88 | 89 | var isConstant: Bool { 90 | return self.bindingSpecifier.tokenKind == .keyword(.let) 91 | } 92 | 93 | var isOptional: Bool { 94 | 95 | return self.bindings.contains(where: { 96 | $0.typeAnnotation?.type.is(OptionalTypeSyntax.self) ?? false 97 | }) 98 | 99 | } 100 | 101 | var isImplicitlyUnwrappedOptional: Bool { 102 | return self.bindings.contains(where: { 103 | $0.typeAnnotation?.type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) ?? false 104 | }) 105 | } 106 | 107 | var typeSyntax: TypeSyntax? { 108 | return self.bindings.first?.typeAnnotation?.type 109 | } 110 | 111 | func modifyingTypeAnnotation(_ modifier: (TypeSyntax) -> TypeSyntax) -> VariableDeclSyntax { 112 | let newBindings = self.bindings.map { binding -> PatternBindingSyntax in 113 | if let typeAnnotation = binding.typeAnnotation { 114 | let newType = modifier(typeAnnotation.type) 115 | let newTypeAnnotation = typeAnnotation.with(\.type, newType) 116 | return binding.with(\.typeAnnotation, newTypeAnnotation) 117 | } 118 | return binding 119 | } 120 | 121 | return self.with(\.bindings, .init(newBindings)) 122 | } 123 | 124 | func addInitializer(_ initializer: InitializerClauseSyntax) -> VariableDeclSyntax { 125 | let newBindings = self.bindings.map { binding -> PatternBindingSyntax in 126 | if binding.initializer == nil { 127 | return binding.with(\.initializer, initializer) 128 | } 129 | return binding 130 | } 131 | 132 | return self.with(\.bindings, .init(newBindings)) 133 | 134 | } 135 | 136 | func modifyingInit(_ modifier: (InitializerClauseSyntax) -> InitializerClauseSyntax) 137 | -> VariableDeclSyntax 138 | { 139 | 140 | let newBindings = self.bindings.map { binding -> PatternBindingSyntax in 141 | if let initializer = binding.initializer { 142 | let newInitializer = modifier(initializer) 143 | return binding.with(\.initializer, newInitializer) 144 | } 145 | return binding 146 | } 147 | 148 | return self.with(\.bindings, .init(newBindings)) 149 | } 150 | 151 | var name: String { 152 | return self.bindings.first?.pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? "" 153 | } 154 | 155 | func renamingIdentifier(with newName: String) -> VariableDeclSyntax { 156 | let newBindings = self.bindings.map { binding -> PatternBindingSyntax in 157 | 158 | if let identifierPattern = binding.pattern.as(IdentifierPatternSyntax.self) { 159 | 160 | let propertyName = identifierPattern.identifier.text 161 | 162 | let newIdentifierPattern = identifierPattern.with( 163 | \.identifier, "\(raw: newName)\(raw: propertyName)") 164 | return binding.with(\.pattern, .init(newIdentifierPattern)) 165 | } 166 | return binding 167 | } 168 | 169 | return self.with(\.bindings, .init(newBindings)) 170 | } 171 | 172 | func makeDidSetDoBlock() -> DoStmtSyntax { 173 | guard let didSetBlock = self.didSetBlock else { 174 | return .init(body: "{}") 175 | } 176 | 177 | return .init(body: didSetBlock) 178 | } 179 | 180 | func makeWillSetDoBlock() -> DoStmtSyntax { 181 | guard let willSetBlock = self.willSetBlock else { 182 | return .init(body: "{}") 183 | } 184 | 185 | return .init(body: willSetBlock) 186 | } 187 | 188 | var getBlock: CodeBlockItemListSyntax? { 189 | for binding in self.bindings { 190 | if let accessorBlock = binding.accessorBlock { 191 | switch accessorBlock.accessors { 192 | case .accessors(let accessors): 193 | for accessor in accessors { 194 | if accessor.accessorSpecifier.tokenKind == .keyword(.get) { 195 | return accessor.body?.statements 196 | } 197 | } 198 | case .getter(let codeBlock): 199 | return codeBlock 200 | } 201 | } 202 | } 203 | return nil 204 | } 205 | 206 | var didSetBlock: CodeBlockSyntax? { 207 | for binding in self.bindings { 208 | if let accessorBlock = binding.accessorBlock { 209 | switch accessorBlock.accessors { 210 | case .accessors(let accessors): 211 | for accessor in accessors { 212 | if accessor.accessorSpecifier.tokenKind == .keyword(.didSet) { 213 | return accessor.body 214 | } 215 | } 216 | case .getter(let _): 217 | return nil 218 | } 219 | } 220 | } 221 | return nil 222 | } 223 | 224 | var willSetBlock: CodeBlockSyntax? { 225 | for binding in self.bindings { 226 | if let accessorBlock = binding.accessorBlock { 227 | switch accessorBlock.accessors { 228 | case .accessors(let accessors): 229 | for accessor in accessors { 230 | if accessor.accessorSpecifier.tokenKind == .keyword(.willSet) { 231 | return accessor.body 232 | } 233 | } 234 | case .getter(let _): 235 | return nil 236 | } 237 | } 238 | } 239 | return nil 240 | } 241 | 242 | var isComputed: Bool { 243 | for binding in self.bindings { 244 | if let accessorBlock = binding.accessorBlock { 245 | switch accessorBlock.accessors { 246 | case .accessors(let accessors): 247 | for accessor in accessors { 248 | if accessor.accessorSpecifier.tokenKind == .keyword(.get) { 249 | return true 250 | } 251 | } 252 | case .getter: 253 | return true 254 | } 255 | } 256 | } 257 | return false 258 | } 259 | } 260 | 261 | extension TypeSyntax { 262 | func removingOptionality() -> Self { 263 | if let optionalType = self.as(OptionalTypeSyntax.self) { 264 | return optionalType.wrappedType 265 | } else if let implicitlyUnwrappedOptionalType = self.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) { 266 | return implicitlyUnwrappedOptionalType.wrappedType 267 | } else { 268 | return self 269 | } 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/GraphTrackingMapTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import StateGraph 5 | 6 | @Suite("GraphTrackingMap Tests") 7 | struct GraphTrackingMapTests { 8 | 9 | @Test 10 | func basicProjection() async throws { 11 | let firstName = Stored(wrappedValue: "John") 12 | let lastName = Stored(wrappedValue: "Doe") 13 | 14 | var receivedValues: [String] = [] 15 | 16 | await confirmation(expectedCount: 3) { confirm in 17 | let cancellable = withGraphTracking { 18 | withGraphTrackingMap { 19 | "\(firstName.wrappedValue) \(lastName.wrappedValue)" 20 | } onChange: { fullName in 21 | receivedValues.append(fullName) 22 | print("Full name: \(fullName)") 23 | confirm() 24 | } 25 | } 26 | 27 | // Initial value should be captured 28 | try? await Task.sleep(nanoseconds: 50_000_000) 29 | 30 | // Change first name 31 | Task { 32 | firstName.wrappedValue = "Jane" 33 | } 34 | try? await Task.sleep(nanoseconds: 100_000_000) 35 | 36 | // Change last name 37 | Task { 38 | lastName.wrappedValue = "Smith" 39 | } 40 | try? await Task.sleep(nanoseconds: 100_000_000) 41 | 42 | cancellable.cancel() 43 | } 44 | 45 | #expect(receivedValues == ["John Doe", "Jane Doe", "Jane Smith"]) 46 | } 47 | 48 | @Test 49 | func distinctFilteringAutomatic() async throws { 50 | let value = Stored(wrappedValue: 10) 51 | 52 | var receivedValues: [Int] = [] 53 | 54 | await confirmation(expectedCount: 3) { confirm in 55 | let cancellable = withGraphTracking { 56 | withGraphTrackingMap { 57 | value.wrappedValue 58 | } onChange: { val in 59 | receivedValues.append(val) 60 | print("Value: \(val)") 61 | confirm() 62 | } 63 | } 64 | 65 | try? await Task.sleep(nanoseconds: 50_000_000) 66 | 67 | // Change to different value 68 | Task { 69 | value.wrappedValue = 20 70 | } 71 | try? await Task.sleep(nanoseconds: 100_000_000) 72 | 73 | // Change to same value (should be filtered out - no confirmation call) 74 | Task { 75 | value.wrappedValue = 20 76 | } 77 | try? await Task.sleep(nanoseconds: 100_000_000) 78 | 79 | // Change to different value again 80 | Task { 81 | value.wrappedValue = 30 82 | } 83 | try? await Task.sleep(nanoseconds: 100_000_000) 84 | 85 | cancellable.cancel() 86 | } 87 | 88 | #expect(receivedValues == [10, 20, 30]) 89 | } 90 | 91 | @Test 92 | func multipleNodeProjection() async throws { 93 | let x = Stored(wrappedValue: 1) 94 | let y = Stored(wrappedValue: 2) 95 | let z = Stored(wrappedValue: 3) // Not accessed in projection 96 | 97 | var receivedSums: [Int] = [] 98 | 99 | await confirmation(expectedCount: 3) { confirm in 100 | let cancellable = withGraphTracking { 101 | withGraphTrackingMap { 102 | x.wrappedValue + y.wrappedValue 103 | } onChange: { sum in 104 | receivedSums.append(sum) 105 | print("Sum: \(sum)") 106 | confirm() 107 | } 108 | } 109 | 110 | try? await Task.sleep(nanoseconds: 50_000_000) 111 | 112 | // Change x 113 | Task { 114 | x.wrappedValue = 5 115 | } 116 | try? await Task.sleep(nanoseconds: 100_000_000) 117 | 118 | // Change y 119 | Task { 120 | y.wrappedValue = 10 121 | } 122 | try? await Task.sleep(nanoseconds: 100_000_000) 123 | 124 | // Change z (not tracked, should not trigger - no confirmation call) 125 | Task { 126 | z.wrappedValue = 100 127 | } 128 | try? await Task.sleep(nanoseconds: 100_000_000) 129 | 130 | cancellable.cancel() 131 | } 132 | 133 | #expect(receivedSums == [3, 7, 15]) 134 | } 135 | 136 | @Test 137 | func conditionalProjection() async throws { 138 | let useFullName = Stored(wrappedValue: true) 139 | let firstName = Stored(wrappedValue: "John") 140 | let lastName = Stored(wrappedValue: "Doe") 141 | let nickname = Stored(wrappedValue: "Johnny") 142 | 143 | var receivedNames: [String] = [] 144 | 145 | await confirmation(expectedCount: 4) { confirm in 146 | let cancellable = withGraphTracking { 147 | withGraphTrackingMap { 148 | if useFullName.wrappedValue { 149 | return "\(firstName.wrappedValue) \(lastName.wrappedValue)" 150 | } else { 151 | return nickname.wrappedValue 152 | } 153 | } onChange: { name in 154 | receivedNames.append(name) 155 | print("Name: \(name)") 156 | confirm() 157 | } 158 | } 159 | 160 | try? await Task.sleep(nanoseconds: 50_000_000) 161 | 162 | // Change firstName (tracked) 163 | Task { 164 | firstName.wrappedValue = "Jane" 165 | } 166 | try? await Task.sleep(nanoseconds: 100_000_000) 167 | 168 | // Change nickname (not tracked when useFullName is true - no confirmation) 169 | Task { 170 | nickname.wrappedValue = "JJ" 171 | } 172 | try? await Task.sleep(nanoseconds: 100_000_000) 173 | 174 | // Switch to nickname mode 175 | Task { 176 | useFullName.wrappedValue = false 177 | } 178 | try? await Task.sleep(nanoseconds: 100_000_000) 179 | 180 | // Now nickname changes should be tracked 181 | Task { 182 | nickname.wrappedValue = "Jay" 183 | } 184 | try? await Task.sleep(nanoseconds: 100_000_000) 185 | 186 | // firstName changes should NOT trigger (not accessed anymore - no confirmation) 187 | Task { 188 | firstName.wrappedValue = "Bob" 189 | } 190 | try? await Task.sleep(nanoseconds: 100_000_000) 191 | 192 | cancellable.cancel() 193 | } 194 | 195 | #expect(receivedNames == ["John Doe", "Jane Doe", "JJ", "Jay"]) 196 | } 197 | 198 | @Test 199 | func customFilter() async throws { 200 | struct ThresholdFilter: Filter { 201 | let threshold: Int 202 | private var lastValue: Int? 203 | 204 | init(threshold: Int) { 205 | self.threshold = threshold 206 | self.lastValue = nil 207 | } 208 | 209 | mutating func send(value: Int) -> Int? { 210 | guard let last = lastValue else { 211 | lastValue = value 212 | return value 213 | } 214 | if abs(value - last) >= threshold { 215 | lastValue = value 216 | return value 217 | } 218 | return nil 219 | } 220 | } 221 | 222 | let value = Stored(wrappedValue: 0) 223 | var receivedValues: [Int] = [] 224 | 225 | await confirmation(expectedCount: 3) { confirm in 226 | let cancellable = withGraphTracking { 227 | withGraphTrackingMap( 228 | { value.wrappedValue }, 229 | filter: ThresholdFilter(threshold: 5) 230 | ) { val in 231 | receivedValues.append(val) 232 | print("Significant change: \(val)") 233 | confirm() 234 | } 235 | } 236 | 237 | try? await Task.sleep(nanoseconds: 50_000_000) 238 | 239 | // Small change (< 5, should be filtered - no confirmation) 240 | Task { 241 | value.wrappedValue = 3 242 | } 243 | try? await Task.sleep(nanoseconds: 100_000_000) 244 | 245 | // Significant change (>= 5) 246 | Task { 247 | value.wrappedValue = 6 248 | } 249 | try? await Task.sleep(nanoseconds: 100_000_000) 250 | 251 | // Another small change (no confirmation) 252 | Task { 253 | value.wrappedValue = 8 254 | } 255 | try? await Task.sleep(nanoseconds: 100_000_000) 256 | 257 | // Another significant change 258 | Task { 259 | value.wrappedValue = 15 260 | } 261 | try? await Task.sleep(nanoseconds: 100_000_000) 262 | 263 | cancellable.cancel() 264 | } 265 | 266 | #expect(receivedValues == [0, 6, 15]) 267 | } 268 | 269 | @Test 270 | func passthroughFilterAllowsDuplicates() async throws { 271 | let value = Stored(wrappedValue: 10) 272 | var receivedValues: [Int] = [] 273 | 274 | await confirmation(expectedCount: 3) { confirm in 275 | let cancellable = withGraphTracking { 276 | withGraphTrackingMap( 277 | { value.wrappedValue }, 278 | filter: PassthroughFilter() 279 | ) { val in 280 | receivedValues.append(val) 281 | print("Value (passthrough): \(val)") 282 | confirm() 283 | } 284 | } 285 | 286 | try? await Task.sleep(nanoseconds: 50_000_000) 287 | 288 | // Change to different value 289 | Task { 290 | value.wrappedValue = 20 291 | } 292 | try? await Task.sleep(nanoseconds: 100_000_000) 293 | 294 | // Change to same value (PassthroughFilter allows duplicates) 295 | Task { 296 | value.wrappedValue = 20 297 | } 298 | try? await Task.sleep(nanoseconds: 100_000_000) 299 | 300 | cancellable.cancel() 301 | } 302 | 303 | #expect(receivedValues == [10, 20, 20]) 304 | } 305 | 306 | } 307 | -------------------------------------------------------------------------------- /Sources/StateGraph/StorageAbstraction.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(Observation) 4 | import Observation 5 | #endif 6 | 7 | // MARK: - Storage Protocol 8 | 9 | public protocol Storage: Sendable { 10 | 11 | associatedtype Value 12 | 13 | mutating func loaded(context: StorageContext) 14 | 15 | func unloaded() 16 | 17 | var value: Value { get set } 18 | 19 | } 20 | 21 | public struct StorageContext: Sendable { 22 | 23 | private let onStorageUpdated: @Sendable () -> Void 24 | 25 | init(onStorageUpdated: @Sendable @escaping () -> Void) { 26 | self.onStorageUpdated = onStorageUpdated 27 | } 28 | 29 | public func notifyStorageUpdated() { 30 | onStorageUpdated() 31 | } 32 | 33 | } 34 | 35 | // MARK: - Concrete Storage Implementations 36 | 37 | public struct InMemoryStorage: Storage { 38 | 39 | nonisolated(unsafe) 40 | public var value: Value 41 | 42 | public init(initialValue: consuming Value) { 43 | self.value = initialValue 44 | } 45 | 46 | public func loaded(context: StorageContext) { 47 | 48 | } 49 | 50 | public func unloaded() { 51 | 52 | } 53 | 54 | } 55 | 56 | public final class UserDefaultsStorage: Storage, Sendable { 57 | 58 | nonisolated(unsafe) 59 | private let userDefaults: UserDefaults 60 | private let key: String 61 | private let defaultValue: Value 62 | 63 | nonisolated(unsafe) 64 | private var subscription: NSObjectProtocol? 65 | 66 | nonisolated(unsafe) 67 | private var cachedValue: Value? 68 | 69 | public var value: Value { 70 | get { 71 | if let cachedValue { 72 | return cachedValue 73 | } 74 | let loadedValue = Value._getValue( 75 | from: userDefaults, 76 | forKey: key, 77 | defaultValue: defaultValue 78 | ) 79 | cachedValue = loadedValue 80 | return loadedValue 81 | } 82 | set { 83 | cachedValue = newValue 84 | newValue._setValue(to: userDefaults, forKey: key) 85 | } 86 | } 87 | 88 | nonisolated(unsafe) 89 | private var previousValue: Value? 90 | 91 | public init( 92 | userDefaults: UserDefaults, 93 | key: String, 94 | defaultValue: Value 95 | ) { 96 | self.userDefaults = userDefaults 97 | self.key = key 98 | self.defaultValue = defaultValue 99 | } 100 | 101 | public func loaded(context: StorageContext) { 102 | 103 | previousValue = value 104 | 105 | subscription = NotificationCenter.default 106 | .addObserver( 107 | forName: UserDefaults.didChangeNotification, 108 | object: userDefaults, 109 | queue: nil, 110 | using: { [weak self] _ in 111 | guard let self else { return } 112 | 113 | // Invalidate cache and reload value 114 | self.cachedValue = nil 115 | let value = self.value 116 | guard self.previousValue != value else { 117 | return 118 | } 119 | 120 | self.previousValue = value 121 | 122 | context.notifyStorageUpdated() 123 | } 124 | ) 125 | } 126 | 127 | public func unloaded() { 128 | guard let subscription else { return } 129 | NotificationCenter.default.removeObserver(subscription) 130 | } 131 | } 132 | 133 | // MARK: - Base Stored Node 134 | 135 | public final class _Stored>: Node, Observable, CustomDebugStringConvertible { 136 | 137 | public let lock: NodeLock 138 | 139 | nonisolated(unsafe) 140 | private var storage: S 141 | 142 | #if canImport(Observation) 143 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 144 | private var observationRegistrar: ObservationRegistrar { 145 | return .shared 146 | } 147 | #endif 148 | 149 | public var potentiallyDirty: Bool { 150 | get { 151 | return false 152 | } 153 | set { 154 | fatalError() 155 | } 156 | } 157 | 158 | public let info: NodeInfo 159 | 160 | public var wrappedValue: Value { 161 | get { 162 | 163 | #if canImport(Observation) 164 | if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { 165 | observationRegistrar.access(PointerKeyPathRoot.shared, keyPath: _keyPath(self)) 166 | } 167 | #endif 168 | 169 | lock.lock() 170 | defer { lock.unlock() } 171 | 172 | // record dependency 173 | if let currentNode = ThreadLocal.currentNode.value { 174 | let edge = Edge(from: self, to: currentNode) 175 | outgoingEdges.append(edge) 176 | currentNode.incomingEdges.append(edge) 177 | } 178 | // record tracking 179 | if let registration = ThreadLocal.registration.value { 180 | self.trackingRegistrations.insert(registration) 181 | } 182 | 183 | return storage.value 184 | } 185 | set { 186 | 187 | #if canImport(Observation) 188 | if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { 189 | withMainActor { [observationRegistrar, keyPath = _keyPath(self)] in 190 | observationRegistrar.willSet(PointerKeyPathRoot.shared, keyPath: keyPath) 191 | } 192 | } 193 | 194 | defer { 195 | if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { 196 | withMainActor { [observationRegistrar, keyPath = _keyPath(self)] in 197 | observationRegistrar.didSet(PointerKeyPathRoot.shared, keyPath: keyPath) 198 | } 199 | } 200 | } 201 | #endif 202 | 203 | lock.lock() 204 | 205 | storage.value = newValue 206 | 207 | let _outgoingEdges = outgoingEdges 208 | let _trackingRegistrations = trackingRegistrations 209 | self.trackingRegistrations.removeAll() 210 | 211 | lock.unlock() 212 | 213 | for registration in _trackingRegistrations { 214 | registration.perform() 215 | } 216 | 217 | for edge in _outgoingEdges { 218 | edge.isPending = true 219 | edge.to.potentiallyDirty = true 220 | } 221 | } 222 | } 223 | 224 | private func notifyStorageUpdated() { 225 | #if canImport(Observation) 226 | if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { 227 | // Workaround: SwiftUI will not trigger update if we call only didSet. 228 | // as here is where the value already updated. 229 | withMainActor { [observationRegistrar, keyPath = _keyPath(self)] in 230 | observationRegistrar.willSet(PointerKeyPathRoot.shared, keyPath: keyPath) 231 | observationRegistrar.didSet(PointerKeyPathRoot.shared, keyPath: keyPath) 232 | } 233 | } 234 | #endif 235 | 236 | lock.lock() 237 | 238 | let _outgoingEdges = outgoingEdges 239 | let _trackingRegistrations = trackingRegistrations 240 | self.trackingRegistrations.removeAll() 241 | 242 | lock.unlock() 243 | 244 | for registration in _trackingRegistrations { 245 | registration.perform() 246 | } 247 | 248 | for edge in _outgoingEdges { 249 | edge.isPending = true 250 | edge.to.potentiallyDirty = true 251 | } 252 | } 253 | 254 | public var incomingEdges: ContiguousArray { 255 | get { 256 | fatalError() 257 | } 258 | set { 259 | fatalError() 260 | } 261 | } 262 | 263 | nonisolated(unsafe) 264 | public var outgoingEdges: ContiguousArray = [] 265 | 266 | nonisolated(unsafe) 267 | public var trackingRegistrations: Set = [] 268 | 269 | public init( 270 | _ file: StaticString = #fileID, 271 | _ line: UInt = #line, 272 | _ column: UInt = #column, 273 | name: StaticString? = nil, 274 | storage: consuming S 275 | ) { 276 | self.info = .init( 277 | name: name, 278 | sourceLocation: .init(file: file, line: line, column: column) 279 | ) 280 | self.lock = .init() 281 | self.storage = storage 282 | 283 | self.storage.loaded(context: .init(onStorageUpdated: { [weak self] in 284 | self?.notifyStorageUpdated() 285 | })) 286 | 287 | #if DEBUG 288 | Task { 289 | await NodeStore.shared.register(node: self) 290 | } 291 | #endif 292 | } 293 | 294 | deinit { 295 | // Log.generic.debug("Deinit StoredNode: \(self.info.name.map(String.init) ?? "noname")") 296 | for edge in outgoingEdges { 297 | edge.to.incomingEdges.removeAll(where: { $0 === edge }) 298 | } 299 | outgoingEdges.removeAll() 300 | storage.unloaded() 301 | } 302 | 303 | public func recomputeIfNeeded() { 304 | // no operation 305 | } 306 | 307 | public var debugDescription: String { 308 | let value = storage.value 309 | let typeName = _typeName(type(of: self)) 310 | return "\(typeName)(name=\(info.name.map(String.init) ?? "noname"), value=\(String(describing: value)))" 311 | } 312 | 313 | /// Accesses the value with thread-safe locking. 314 | /// 315 | /// - Parameter body: A closure that takes an inout parameter of the value 316 | /// - Returns: The result of the closure 317 | public borrowing func withLock( 318 | _ body: (inout Value) throws(E) -> Result 319 | ) throws(E) -> Result where E : Error { 320 | lock.lock() 321 | defer { 322 | lock.unlock() 323 | } 324 | let result = try body(&storage.value) 325 | return result 326 | } 327 | 328 | } 329 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/ConcurrencyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import os.lock 3 | 4 | @testable import StateGraph 5 | 6 | final class ConcurrencyTests: XCTestCase { 7 | 8 | func testConcurrentAccess() async throws { 9 | let source = Stored(name: "source", wrappedValue: 0) 10 | let computed = Computed(name: "computed") { _ in source.wrappedValue * 2 } 11 | 12 | await withTaskGroup(of: Void.self) { group in 13 | for i in 0..<100 { 14 | group.addTask { 15 | if i % 2 == 0 { 16 | source.wrappedValue = i 17 | } else { 18 | _ = computed.wrappedValue 19 | } 20 | } 21 | } 22 | } 23 | 24 | // Verify that the final value is correct 25 | XCTAssertEqual(computed.wrappedValue, source.wrappedValue * 2) 26 | } 27 | 28 | func testDependencyTracking() async throws { 29 | let source1 = Stored(name: "source1", wrappedValue: 1) 30 | let source2 = Stored(name: "source2", wrappedValue: 2) 31 | 32 | // Node whose dependencies change based on conditions 33 | let conditional = Computed(name: "conditional") { _ in 34 | if source1.wrappedValue > 5 { 35 | return source2.wrappedValue 36 | } else { 37 | return source1.wrappedValue 38 | } 39 | } 40 | 41 | // Simultaneous access from multiple threads 42 | await withTaskGroup(of: Void.self) { group in 43 | group.addTask { source1.wrappedValue = 10 } 44 | group.addTask { _ = conditional.wrappedValue } 45 | group.addTask { source2.wrappedValue = 20 } 46 | } 47 | 48 | // Check if dependencies were correctly updated 49 | source2.wrappedValue = 30 50 | XCTAssertEqual(conditional.wrappedValue, 30) 51 | } 52 | 53 | func testReentrancy() async throws { 54 | let counter = Stored(name: "counter", wrappedValue: 0) 55 | let trigger = Stored(name: "trigger", wrappedValue: false) 56 | 57 | // Node that may cause reentrancy 58 | let reentrant = Computed(name: "reentrant") { _ in 59 | let value = counter.wrappedValue 60 | if trigger.wrappedValue && value < 5 { 61 | counter.wrappedValue = value + 1 // Trigger recalculation 62 | } 63 | return value 64 | } 65 | 66 | trigger.wrappedValue = true 67 | _ = reentrant.wrappedValue 68 | 69 | XCTAssertLessThanOrEqual(counter.wrappedValue, 5, "Not caught in an infinite loop") 70 | } 71 | 72 | func testComplexDependencyGraph() async throws { 73 | let a = Stored(name: "a", wrappedValue: 1) 74 | let b = Stored(name: "b", wrappedValue: 2) 75 | 76 | let c = Computed(name: "c") { _ in a.wrappedValue + b.wrappedValue } 77 | let d = Computed(name: "d") { _ in b.wrappedValue * 2 } 78 | let e = Computed(name: "e") { _ in c.wrappedValue + d.wrappedValue } 79 | 80 | // Modify multiple source nodes simultaneously 81 | await withTaskGroup(of: Void.self) { group in 82 | group.addTask { a.wrappedValue = 10 } 83 | group.addTask { b.wrappedValue = 20 } 84 | } 85 | 86 | // Verify that final calculation results are correct 87 | XCTAssertEqual(c.wrappedValue, 30) // 10 + 20 88 | XCTAssertEqual(d.wrappedValue, 40) // 20 * 2 89 | XCTAssertEqual(e.wrappedValue, 70) // 30 + 40 90 | } 91 | 92 | func testPropagationTiming() async throws { 93 | let source = Stored(name: "source", wrappedValue: 0) 94 | 95 | let valuesLock = OSAllocatedUnfairLock<[Int]>(initialState: []) 96 | 97 | let computed = Computed(name: "computed") { _ in 98 | let value = source.wrappedValue 99 | valuesLock.withLock { $0.append(value) } 100 | return value 101 | } 102 | 103 | // Initial access 104 | _ = computed.wrappedValue 105 | 106 | // Make multiple changes in rapid succession 107 | for i in 1...10 { 108 | source.wrappedValue = i 109 | // Intentionally wait a bit 110 | try await Task.sleep(nanoseconds: 1_000_000) 111 | } 112 | 113 | // Check if all changes were properly propagated 114 | _ = computed.wrappedValue 115 | let computedValues = valuesLock.withLock { $0 } 116 | XCTAssertEqual(computedValues.last, 10) 117 | } 118 | 119 | func testHighConcurrency() async throws { 120 | // Test with many nodes and high concurrency 121 | let sources = (0..<10).map { Stored(wrappedValue: $0) } 122 | 123 | let computed = Computed(name: "sum") { _ in 124 | sources.reduce(0) { $0 + $1.wrappedValue } 125 | } 126 | 127 | // Check initial sum 128 | XCTAssertEqual(computed.wrappedValue, 45) // 0+1+2+...+9 = 45 129 | 130 | // Change values with very high concurrency 131 | await withTaskGroup(of: Void.self) { group in 132 | for _ in 0..<1000 { 133 | group.addTask { 134 | let randomIndex = Int.random(in: 0..(initialState: []) 160 | 161 | // Set different values concurrently 162 | await withTaskGroup(of: Void.self) { group in 163 | for i in 1...1000 { 164 | group.addTask { 165 | a.wrappedValue = i 166 | b.wrappedValue = i 167 | 168 | // Read simultaneously - ideally should be pairs of the same value 169 | let result = sum.wrappedValue 170 | if result != 0 && result != i * 2 { 171 | inconsistenciesLock.withLock { 172 | $0.append("Inconsistency: \(result) (expected 0 or \(i*2))") 173 | } 174 | } 175 | } 176 | } 177 | } 178 | 179 | // Report inconsistencies 180 | let inconsistencies = inconsistenciesLock.withLock { $0 } 181 | if !inconsistencies.isEmpty { 182 | print("Number of detected inconsistencies: \(inconsistencies.count)") 183 | print( 184 | "Examples of inconsistencies (max 10): \(inconsistencies.prefix(10).joined(separator: ", "))" 185 | ) 186 | 187 | // Commented out to prevent test failure 188 | // XCTFail("Detected \(inconsistencies.count) inconsistencies during concurrent updates") 189 | } 190 | 191 | // Verify final consistency 192 | // At the end of the test, it should have the correct final state 193 | let finalA = a.wrappedValue 194 | let finalB = b.wrappedValue 195 | let finalSum = sum.wrappedValue 196 | 197 | XCTAssertEqual(finalSum, finalA + finalB, "Final state should be consistent") 198 | } 199 | 200 | func testEdgePropagation() async throws { 201 | // Test if edge propagation works correctly 202 | let source = Stored(name: "source", wrappedValue: 0) 203 | 204 | // Multiple computed nodes that depend on the source 205 | let computed1 = Computed(name: "computed1") { _ in source.wrappedValue * 2 } 206 | let computed2 = Computed(name: "computed2") { _ in source.wrappedValue + 10 } 207 | 208 | // Final node that depends on both computed nodes 209 | let finalNode = Computed(name: "final") { _ in 210 | computed1.wrappedValue + computed2.wrappedValue 211 | } 212 | 213 | // // Verify initial state 214 | XCTAssertEqual(computed1.wrappedValue, 0) 215 | XCTAssertEqual(computed2.wrappedValue, 10) 216 | XCTAssertEqual(finalNode.wrappedValue, 10) 217 | // Change source value 218 | source.wrappedValue = 5 219 | // Check if all dependent nodes are appropriately updated 220 | XCTAssertEqual(computed1.wrappedValue, 10) 221 | XCTAssertEqual(computed2.wrappedValue, 15) 222 | XCTAssertEqual(finalNode.wrappedValue, 25) 223 | 224 | // Test change propagation in concurrent access environment 225 | await withTaskGroup(of: Void.self) { group in 226 | for i in 1...100 { 227 | group.addTask { 228 | source.wrappedValue = i 229 | // Check propagation consistency 230 | let c1 = computed1.wrappedValue 231 | let c2 = computed2.wrappedValue 232 | let fn = finalNode.wrappedValue 233 | 234 | // May not always be perfectly consistent, but should eventually be correct 235 | if fn != c1 + c2 { 236 | print( 237 | "Temporary inconsistency detected: final=\(fn), c1=\(c1), c2=\(c2), expected=\(c1+c2)" 238 | ) 239 | } 240 | } 241 | } 242 | } 243 | 244 | // Final state should have consistent values 245 | let c1Final = computed1.wrappedValue 246 | let c2Final = computed2.wrappedValue 247 | let fnFinal = finalNode.wrappedValue 248 | 249 | XCTAssertEqual(c1Final, source.wrappedValue * 2) 250 | XCTAssertEqual(c2Final, source.wrappedValue + 10) 251 | XCTAssertEqual(fnFinal, c1Final + c2Final) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /Sources/StateGraph/Observation/withTracking.swift: -------------------------------------------------------------------------------- 1 | import Observation 2 | 3 | /// Tracks access to the properties of StoredNode or Computed. 4 | /// Similarly to Observation.withObservationTracking, didChange runs one time after property changes applied. 5 | /// To observe properties continuously, use ``withContinuousStateGraphTracking``. 6 | @discardableResult 7 | func withStateGraphTracking( 8 | apply: () -> R, 9 | @_inheritActorContext didChange: @escaping @isolated(any) @Sendable () -> Void 10 | ) -> R { 11 | #if false // #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 12 | return withObservationTracking( 13 | apply, 14 | onChange: { 15 | Task { 16 | await didChange() 17 | } 18 | } 19 | ) 20 | #else 21 | /// Need this for now as https://github.com/VergeGroup/swift-state-graph/pull/79 22 | let registration = TrackingRegistration(didChange: didChange) 23 | return ThreadLocal.registration.withValue(registration) { 24 | apply() 25 | } 26 | #endif 27 | 28 | } 29 | 30 | public enum StateGraphTrackingContinuation: Sendable { 31 | case stop 32 | case next 33 | } 34 | 35 | /// Tracks access to the properties of StoredNode or Computed. 36 | /// Continuously tracks until `didChange` returns `.stop`. 37 | /// It does not provides update of the properties granurarly. some frequency of updates may be aggregated into single event. 38 | func withContinuousStateGraphTracking( 39 | apply: @escaping () -> R, 40 | didChange: @escaping () -> StateGraphTrackingContinuation, 41 | isolation: isolated (any Actor)? = #isolation 42 | ) { 43 | 44 | let applyBox = UnsafeSendable(apply) 45 | let didChangeBox = UnsafeSendable(didChange) 46 | 47 | withStateGraphTracking(apply: apply) { 48 | let continuation = perform(didChangeBox._value, isolation: isolation) 49 | switch continuation { 50 | case .stop: 51 | break 52 | case .next: 53 | // continue tracking on next event loop. 54 | // It uses isolation and task dispatching to ensure apply closure is called on the same actor. 55 | withContinuousStateGraphTracking( 56 | apply: applyBox._value, 57 | didChange: didChangeBox._value, 58 | isolation: isolation 59 | ) 60 | } 61 | } 62 | } 63 | 64 | /// Creates an `AsyncStream` that emits projected values whenever tracked StateGraph nodes change. 65 | /// 66 | /// This function provides a convenient way to observe StateGraph node changes using Swift's 67 | /// async/await concurrency model. The stream emits the current value immediately upon iteration, 68 | /// then emits new values whenever any accessed node changes. 69 | /// 70 | /// ## Basic Usage 71 | /// ```swift 72 | /// let counter = Stored(wrappedValue: 0) 73 | /// 74 | /// for await value in withStateGraphTrackingStream(apply: { 75 | /// counter.wrappedValue 76 | /// }) { 77 | /// print("Counter: \(value)") 78 | /// } 79 | /// ``` 80 | /// 81 | /// ## Important: Single-Consumer Stream 82 | /// 83 | /// This function returns an `AsyncStream`, which is **single-consumer**. 84 | /// When multiple iterators consume the same stream, values are distributed between them 85 | /// in a racing manner rather than being duplicated to each iterator. 86 | /// 87 | /// ```swift 88 | /// let stream = withStateGraphTrackingStream { model.counter } 89 | /// 90 | /// // ⚠️ Values are NOT duplicated - they compete for values 91 | /// let taskA = Task { for await v in stream { print("A: \(v)") } } 92 | /// let taskB = Task { for await v in stream { print("B: \(v)") } } 93 | /// // Output might be: A: 0, B: 1, A: 2 (racing behavior) 94 | /// ``` 95 | /// 96 | /// ## Multi-Consumer Alternative 97 | /// 98 | /// If you need multiple independent consumers that each receive all values, 99 | /// use ``GraphTrackings`` instead (available on iOS 18+): 100 | /// 101 | /// ```swift 102 | /// // Each iterator gets its own independent stream of all values 103 | /// let trackings = GraphTrackings { model.counter } 104 | /// 105 | /// let taskA = Task { for await v in trackings { print("A: \(v)") } } 106 | /// let taskB = Task { for await v in trackings { print("B: \(v)") } } 107 | /// // Output: A: 0, B: 0, A: 1, B: 1, A: 2, B: 2 (both receive all values) 108 | /// ``` 109 | /// 110 | /// ## Comparison Table 111 | /// 112 | /// | Feature | `withStateGraphTrackingStream` | `GraphTrackings` | 113 | /// |---------|-------------------------------|------------------| 114 | /// | Return Type | `AsyncStream` | `AsyncSequence` | 115 | /// | Consumer Model | Single-consumer | Multi-consumer | 116 | /// | Value Distribution | Racing (values split) | Duplicated (all receive) | 117 | /// | iOS Availability | iOS 13+ | iOS 18+ | 118 | /// 119 | /// - Parameters: 120 | /// - apply: A closure that accesses StateGraph nodes and returns a projected value. 121 | /// This closure is called initially and whenever tracked nodes change. 122 | /// - isolation: The actor isolation context for the tracking. Defaults to the caller's isolation. 123 | /// 124 | /// - Returns: An `AsyncStream` that emits the projected value from `apply` whenever tracked nodes change. 125 | /// 126 | /// - Note: The stream automatically handles cancellation. When the consuming task is cancelled, 127 | /// the internal tracking stops. 128 | /// 129 | /// - SeeAlso: ``GraphTrackings`` for multi-consumer scenarios 130 | /// - SeeAlso: ``withContinuousStateGraphTracking(_:didChange:isolation:)`` for callback-based tracking 131 | public func withStateGraphTrackingStream( 132 | apply: @escaping () -> T, 133 | isolation: isolated (any Actor)? = #isolation 134 | ) -> AsyncStream { 135 | 136 | AsyncStream { (continuation: AsyncStream.Continuation) in 137 | 138 | let isCancelled = OSAllocatedUnfairLock(initialState: false) 139 | 140 | continuation.onTermination = { termination in 141 | isCancelled.withLock { $0 = true } 142 | } 143 | 144 | withContinuousStateGraphTracking( 145 | apply: { 146 | let value = apply() 147 | continuation.yield(value) 148 | }, 149 | didChange: { 150 | if isCancelled.withLock({ $0 }) { 151 | return .stop 152 | } 153 | return .next 154 | }, 155 | isolation: isolation 156 | ) 157 | 158 | } 159 | } 160 | 161 | // MARK: - Internals 162 | 163 | public final class TrackingRegistration: Sendable, Hashable { 164 | 165 | private struct State: Sendable { 166 | var isInvalidated: Bool = false 167 | let didChange: @isolated(any) @Sendable () -> Void 168 | } 169 | 170 | public static func == (lhs: TrackingRegistration, rhs: TrackingRegistration) -> Bool { 171 | lhs === rhs 172 | } 173 | 174 | public func hash(into hasher: inout Hasher) { 175 | hasher.combine(ObjectIdentifier(self)) 176 | } 177 | 178 | private let state: OSAllocatedUnfairLock 179 | 180 | init(didChange: @escaping @isolated(any) @Sendable () -> Void) { 181 | self.state = .init(uncheckedState: 182 | .init( 183 | didChange: didChange 184 | ) 185 | ) 186 | } 187 | 188 | func perform() { 189 | state.withLock { state in 190 | guard state.isInvalidated == false else { 191 | return 192 | } 193 | 194 | // Re-entry Prevention Guard 195 | // ======================== 196 | // 197 | // Problem: Infinite loop when setting unchanged values within tracking handlers 198 | // 199 | // Scenario: 200 | // 1. withGraphTrackingGroup { ... } establishes a tracking context 201 | // 2. Inside the handler, we read a node's value (e.g., node.wrappedValue) 202 | // 3. We set the same value back (e.g., node.wrappedValue = value) 203 | // 4. The setter ALWAYS triggers tracking registrations (no equality check) 204 | // 5. This calls perform() on the same registration that's currently executing 205 | // 6. The handler runs again, repeating steps 2-5 infinitely 206 | // 207 | // Solution: 208 | // Check if the registration trying to perform is the SAME as the one currently 209 | // executing (stored in TaskLocal). If so, skip re-execution to break the cycle. 210 | // 211 | // How it works: 212 | // - TrackingRegistration.registration is a @TaskLocal that holds the currently 213 | // executing registration during handler execution 214 | // - If `self` (the registration being performed) matches the TaskLocal value, 215 | // we're attempting to re-enter the same handler 216 | // - Return early to prevent the infinite loop 217 | // 218 | // Example: 219 | // let node = Stored(wrappedValue: 42) 220 | // withGraphTracking { 221 | // withGraphTrackingGroup { 222 | // let value = node.wrappedValue // Establishes tracking 223 | // node.wrappedValue = value // Without this guard, would loop infinitely 224 | // } 225 | // } 226 | // 227 | // This behavior is similar to Apple's Observation framework. 228 | if ThreadLocal.registration.value == self { 229 | return 230 | } 231 | 232 | state.isInvalidated = true 233 | 234 | let closure = state.didChange 235 | Task { 236 | await closure() 237 | } 238 | } 239 | } 240 | 241 | } 242 | 243 | struct UnsafeSendable: ~Copyable, @unchecked Sendable { 244 | 245 | let _value: V 246 | 247 | init(_ value: consuming V) { 248 | _value = value 249 | } 250 | 251 | } 252 | 253 | func perform( 254 | _ closure: () -> Return, 255 | isolation: isolated (any Actor)? = #isolation 256 | ) 257 | -> Return 258 | { 259 | closure() 260 | } 261 | 262 | -------------------------------------------------------------------------------- /Sources/StateGraph/Primitives/UserDefaultsStored.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - UserDefaultsStored 4 | 5 | /// A protocol for types that can be stored in UserDefaults 6 | public protocol UserDefaultsStorable: Equatable, Sendable { 7 | @_spi(Internal) 8 | static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: Self) -> Self 9 | @_spi(Internal) 10 | func _setValue(to userDefaults: UserDefaults, forKey key: String) 11 | } 12 | 13 | extension UserDefaultsStorable { 14 | @_spi(Internal) 15 | public static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: Self) -> Self { 16 | if userDefaults.object(forKey: key) == nil { 17 | return defaultValue 18 | } 19 | return userDefaults.object(forKey: key) as? Self ?? defaultValue 20 | } 21 | } 22 | 23 | extension Bool: UserDefaultsStorable { 24 | @_spi(Internal) 25 | public static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: Bool) -> Bool { 26 | if userDefaults.object(forKey: key) == nil { 27 | return defaultValue 28 | } 29 | return userDefaults.bool(forKey: key) 30 | } 31 | @_spi(Internal) 32 | public func _setValue(to userDefaults: UserDefaults, forKey key: String) { 33 | userDefaults.set(self, forKey: key) 34 | } 35 | } 36 | 37 | extension Int: UserDefaultsStorable { 38 | @_spi(Internal) 39 | public static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: Int) -> Int { 40 | if userDefaults.object(forKey: key) == nil { 41 | return defaultValue 42 | } 43 | return userDefaults.integer(forKey: key) 44 | } 45 | @_spi(Internal) 46 | public func _setValue(to userDefaults: UserDefaults, forKey key: String) { 47 | userDefaults.set(self, forKey: key) 48 | } 49 | } 50 | 51 | extension Float: UserDefaultsStorable { 52 | @_spi(Internal) 53 | public static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: Float) -> Float { 54 | if userDefaults.object(forKey: key) == nil { 55 | return defaultValue 56 | } 57 | return userDefaults.float(forKey: key) 58 | } 59 | @_spi(Internal) 60 | public func _setValue(to userDefaults: UserDefaults, forKey key: String) { 61 | userDefaults.set(self, forKey: key) 62 | } 63 | } 64 | 65 | extension Double: UserDefaultsStorable { 66 | @_spi(Internal) 67 | public static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: Double) -> Double { 68 | if userDefaults.object(forKey: key) == nil { 69 | return defaultValue 70 | } 71 | return userDefaults.double(forKey: key) 72 | } 73 | @_spi(Internal) 74 | public func _setValue(to userDefaults: UserDefaults, forKey key: String) { 75 | userDefaults.set(self, forKey: key) 76 | } 77 | } 78 | 79 | extension String: UserDefaultsStorable { 80 | @_spi(Internal) 81 | public static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: String) -> String { 82 | return userDefaults.string(forKey: key) ?? defaultValue 83 | } 84 | @_spi(Internal) 85 | public func _setValue(to userDefaults: UserDefaults, forKey key: String) { 86 | userDefaults.set(self, forKey: key) 87 | } 88 | } 89 | 90 | extension Data: UserDefaultsStorable { 91 | @_spi(Internal) 92 | public static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: Data) -> Data { 93 | return userDefaults.data(forKey: key) ?? defaultValue 94 | } 95 | @_spi(Internal) 96 | public func _setValue(to userDefaults: UserDefaults, forKey key: String) { 97 | userDefaults.set(self, forKey: key) 98 | } 99 | } 100 | 101 | extension Date: UserDefaultsStorable { 102 | @_spi(Internal) 103 | public static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: Date) -> Date { 104 | return userDefaults.object(forKey: key) as? Date ?? defaultValue 105 | } 106 | @_spi(Internal) 107 | public func _setValue(to userDefaults: UserDefaults, forKey key: String) { 108 | userDefaults.set(self, forKey: key) 109 | } 110 | } 111 | 112 | private enum Static { 113 | static let decoder = JSONDecoder() 114 | static let encoder = JSONEncoder() 115 | } 116 | 117 | extension UserDefaultsStorable where Self: Codable { 118 | public static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: Self) -> Self { 119 | guard let data = userDefaults.data(forKey: key) else { 120 | return defaultValue 121 | } 122 | do { 123 | return try Static.decoder.decode(Self.self, from: data) 124 | } catch { 125 | return defaultValue 126 | } 127 | } 128 | 129 | public func _setValue(to userDefaults: UserDefaults, forKey key: String) { 130 | do { 131 | let data = try Static.encoder.encode(self) 132 | userDefaults.set(data, forKey: key) 133 | } catch { 134 | // If encoding fails, remove the key to avoid inconsistent state 135 | userDefaults.removeObject(forKey: key) 136 | } 137 | } 138 | } 139 | 140 | extension Optional: UserDefaultsStorable where Wrapped: UserDefaultsStorable { 141 | @_spi(Internal) 142 | public static func _getValue(from userDefaults: UserDefaults, forKey key: String, defaultValue: Self) -> Self { 143 | if userDefaults.object(forKey: key) == nil { 144 | return defaultValue 145 | } 146 | // Try to get the wrapped value using a temporary dummy value 147 | // This is a workaround since we can't easily create a default Wrapped value 148 | let tempValue = userDefaults.object(forKey: key) 149 | if tempValue == nil { 150 | return nil 151 | } 152 | return tempValue as? Wrapped 153 | } 154 | @_spi(Internal) 155 | public func _setValue(to userDefaults: UserDefaults, forKey key: String) { 156 | if let value = self { 157 | value._setValue(to: userDefaults, forKey: key) 158 | } else { 159 | userDefaults.removeObject(forKey: key) 160 | } 161 | } 162 | } 163 | 164 | //extension Array: UserDefaultsStorable where Element: UserDefaultsStorable { 165 | // public func setValue(to userDefaults: UserDefaults, forKey key: String) { 166 | // userDefaults.set(self, forKey: key) 167 | // } 168 | //} 169 | // 170 | //extension Dictionary: UserDefaultsStorable where Key == String, Value: UserDefaultsStorable { 171 | // public func setValue(to userDefaults: UserDefaults, forKey key: String) { 172 | // userDefaults.set(self, forKey: key) 173 | // } 174 | //} 175 | 176 | /// A node that functions as an endpoint in a Directed Acyclic Graph (DAG) and persists its value to UserDefaults. 177 | /// 178 | /// `UserDefaultsStored` can have its value set directly from the outside, and changes to its value 179 | /// automatically propagate to dependent nodes. The value is automatically persisted to UserDefaults 180 | /// and restored when the node is recreated. 181 | /// 182 | /// - When value changes: Changes propagate to all dependent nodes and are persisted to UserDefaults 183 | /// - When value is accessed: Dependencies are recorded and the value is loaded from UserDefaults if needed 184 | public typealias UserDefaultsStored = _Stored> 185 | 186 | extension _Stored where S == UserDefaultsStorage { 187 | /// Initializes a UserDefaultsStored node with standard UserDefaults. 188 | /// 189 | /// - Parameters: 190 | /// - file: The file where the node is created (defaults to current file) 191 | /// - line: The line number where the node is created (defaults to current line) 192 | /// - column: The column number where the node is created (defaults to current column) 193 | /// - group: The group name of the node (defaults to nil) 194 | /// - name: The name of the node (defaults to nil) 195 | /// - key: The UserDefaults key to store the value 196 | /// - defaultValue: The default value if no value exists in UserDefaults 197 | public convenience init( 198 | _ file: StaticString = #fileID, 199 | _ line: UInt = #line, 200 | _ column: UInt = #column, 201 | name: StaticString? = nil, 202 | key: String, 203 | defaultValue: Value 204 | ) { 205 | let storage = UserDefaultsStorage( 206 | userDefaults: .standard, 207 | key: key, 208 | defaultValue: defaultValue 209 | ) 210 | self.init( 211 | file, 212 | line, 213 | column, 214 | name: name, 215 | storage: storage 216 | ) 217 | } 218 | 219 | /// Initializes a UserDefaultsStored node with a specific UserDefaults suite. 220 | /// 221 | /// - Parameters: 222 | /// - file: The file where the node is created (defaults to current file) 223 | /// - line: The line number where the node is created (defaults to current line) 224 | /// - column: The column number where the node is created (defaults to current column) 225 | /// - group: The group name of the node (defaults to nil) 226 | /// - name: The name of the node (defaults to nil) 227 | /// - suite: The UserDefaults suite name 228 | /// - key: The UserDefaults key to store the value 229 | /// - defaultValue: The default value if no value exists in UserDefaults 230 | public convenience init( 231 | _ file: StaticString = #fileID, 232 | _ line: UInt = #line, 233 | _ column: UInt = #column, 234 | name: StaticString? = nil, 235 | suite: String, 236 | key: String, 237 | defaultValue: Value 238 | ) { 239 | let storage = UserDefaultsStorage( 240 | userDefaults: UserDefaults(suiteName: suite) ?? .standard, 241 | key: key, 242 | defaultValue: defaultValue 243 | ) 244 | self.init( 245 | file, 246 | line, 247 | column, 248 | name: name, 249 | storage: storage 250 | ) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /Tests/StateGraphTests/KeyPathTests.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import Testing 2 | @preconcurrency import Foundation 3 | @testable import StateGraph 4 | 5 | @Suite("KeyPath Uniqueness Tests") 6 | struct KeyPathTests { 7 | 8 | @Test("Different objects generate different KeyPaths") 9 | func keyPathUniqueness() { 10 | // Verify that different objects generate different KeyPaths 11 | let object1 = NSObject() 12 | let object2 = NSObject() 13 | 14 | let keyPath1 = _keyPath(object1) 15 | let keyPath2 = _keyPath(object2) 16 | 17 | // Different objects should produce different KeyPaths 18 | #expect(keyPath1 != keyPath2, "Different objects should produce different KeyPaths") 19 | } 20 | 21 | @Test("Same object generates consistent KeyPaths") 22 | func keyPathConsistency() { 23 | // Verify that the same object generates consistent KeyPaths 24 | let object = NSObject() 25 | 26 | let keyPath1 = _keyPath(object) 27 | let keyPath2 = _keyPath(object) 28 | 29 | // Same object should produce identical KeyPaths 30 | #expect(keyPath1 == keyPath2, "Same object should produce identical KeyPaths") 31 | } 32 | 33 | @Test("KeyPath uniqueness with multiple objects") 34 | func keyPathUniquenessWithMultipleObjects() { 35 | // Verify KeyPath uniqueness with multiple objects 36 | let objects = (0..<100).map { _ in NSObject() } 37 | let keyPaths = objects.map { _keyPath($0) } 38 | 39 | // All KeyPaths should be unique 40 | for i in 0.. & Sendable)? 80 | 81 | autoreleasepool { 82 | let object = NSObject() 83 | keyPath = _keyPath(object) 84 | 85 | // KeyPath should be valid while the object is alive 86 | #expect(keyPath != nil) 87 | } 88 | 89 | // KeyPath itself should be retained even after the object is deallocated 90 | #expect(keyPath != nil) 91 | } 92 | 93 | @Test("Sendable protocol conformance") 94 | func keyPathSendableConformance() { 95 | // Verify that KeyPath conforms to Sendable protocol 96 | let object = NSObject() 97 | let keyPath = _keyPath(object) 98 | 99 | // Verify conformance to Sendable protocol at type level 100 | let sendableKeyPath: any KeyPath & Sendable = keyPath 101 | #expect(sendableKeyPath != nil) 102 | } 103 | 104 | @Test("Concurrent KeyPath generation") 105 | func concurrentKeyPathGeneration() async { 106 | // Verify safety of KeyPath generation in concurrent environments 107 | let keyPaths = OSAllocatedUnfairLock<[any KeyPath & Sendable]>(initialState: []) 108 | let objects = OSAllocatedUnfairLock<[NSObject]>(initialState: []) 109 | 110 | await withTaskGroup(of: Void.self) { group in 111 | for _ in 0..<10 { 112 | group.addTask { 113 | // Create objects within each task to avoid data races 114 | let object = NSObject() 115 | let keyPath = _keyPath(object) 116 | 117 | // Retain objects to prevent memory address reuse 118 | objects.withLock { objects in 119 | objects.append(object) 120 | } 121 | 122 | keyPaths.withLock { keyPaths in 123 | keyPaths.append(keyPath) 124 | } 125 | } 126 | } 127 | } 128 | 129 | // Verify the count of concurrently generated KeyPaths 130 | let finalKeyPaths = keyPaths.withLock { $0 } 131 | let finalObjects = objects.withLock { $0 } 132 | 133 | #expect(finalKeyPaths.count == 10, "Should have generated 10 KeyPaths") 134 | #expect(finalObjects.count == 10, "Should have created 10 objects") 135 | 136 | // Verify that KeyPaths are unique while objects are alive 137 | for i in 0.. & Sendable)? 183 | var firstAddress: String? 184 | 185 | // Create first object and get its KeyPath 186 | autoreleasepool { 187 | let object1 = NSObject() 188 | firstKeyPath = _keyPath(object1) 189 | firstAddress = firstKeyPath?._kvcKeyPathString 190 | } 191 | 192 | // Promote garbage collection 193 | for _ in 0..<100 { 194 | autoreleasepool { 195 | _ = NSObject() 196 | } 197 | } 198 | 199 | // Create new object with KeyPath 200 | let object2 = NSObject() 201 | let secondKeyPath = _keyPath(object2) 202 | let secondAddress = secondKeyPath._kvcKeyPathString 203 | 204 | // Compare KeyPath objects themselves 205 | if let firstKeyPath = firstKeyPath { 206 | // When memory addresses are reused, KeyPaths may become the same 207 | // This is an implementation detail and may not always be different 208 | let keyPathsAreDifferent = !(firstKeyPath == secondKeyPath) 209 | 210 | // Compare address strings to verify actual behavior 211 | if let firstAddr = firstAddress, let secondAddr = secondAddress { 212 | print("First address: \(firstAddr)") 213 | print("Second address: \(secondAddr)") 214 | 215 | if firstAddr == secondAddr { 216 | // When memory addresses are reused, KeyPaths should be equal 217 | #expect(!keyPathsAreDifferent, "When memory addresses are reused, KeyPaths should be equal") 218 | } else { 219 | // When memory addresses are different, KeyPaths should be different 220 | #expect(keyPathsAreDifferent, "When memory addresses are different, KeyPaths should be different") 221 | } 222 | } 223 | } 224 | } 225 | 226 | @Test("Implementation details verification") 227 | func keyPathImplementationDetails() { 228 | // Verify KeyPath implementation details 229 | let object = NSObject() 230 | let keyPath = _keyPath(object) 231 | 232 | // Verify that KeyPath string representation has appropriate format 233 | if let keyPathString = keyPath._kvcKeyPathString { 234 | #expect(keyPathString.hasPrefix("pointer:"), "KeyPath string should start with 'pointer:'") 235 | } 236 | 237 | // Verify relationship between object memory address and KeyPath 238 | let objectPointer = Unmanaged.passUnretained(object).toOpaque() 239 | let expectedKeyPath = \PointerKeyPathRoot[pointer: objectPointer] 240 | 241 | // KeyPaths created from the same pointer should be equal 242 | #expect(keyPath == expectedKeyPath, "KeyPath should match expected KeyPath from same pointer") 243 | } 244 | } -------------------------------------------------------------------------------- /Sources/StateGraph/Observation/withGraphTrackingMap.swift: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | Tracks graph nodes accessed during projection and calls onChange when filtered values change. 4 | 5 | This function enables reactive processing where you project (compute) a derived value from 6 | multiple nodes, and receive change notifications only for distinct values. It's similar to 7 | `withGraphTrackingGroup`, but adds value projection and filtering capabilities. 8 | 9 | ## Behavior 10 | - Must be called within a `withGraphTracking` scope 11 | - The `applier` closure is executed initially and re-executed whenever any accessed nodes change 12 | - Only nodes accessed during `applier` execution are tracked 13 | - The projected value is passed through a `DistinctFilter` (for `Equatable` types) 14 | - `onChange` is only called when the filtered value passes through (i.e., when it's different) 15 | 16 | ## Example: Computed Value Tracking 17 | ```swift 18 | let firstName = Stored(wrappedValue: "John") 19 | let lastName = Stored(wrappedValue: "Doe") 20 | let age = Stored(wrappedValue: 30) 21 | 22 | withGraphTracking { 23 | // Track full name changes, ignore age changes 24 | withGraphTrackingMap { 25 | "\(firstName.wrappedValue) \(lastName.wrappedValue)" 26 | } onChange: { fullName in 27 | print("Name changed to: \(fullName)") // Only called when name actually changes 28 | } 29 | } 30 | 31 | firstName.wrappedValue = "Jane" // ✓ Triggers: "Jane Doe" 32 | lastName.wrappedValue = "Smith" // ✓ Triggers: "Jane Smith" 33 | age.wrappedValue = 31 // ✗ Doesn't trigger (age not accessed in applier) 34 | firstName.wrappedValue = "Jane" // ✗ Doesn't trigger (same value, filtered by DistinctFilter) 35 | ``` 36 | 37 | ## Use Cases 38 | - Derived state tracking: Monitor computed values from multiple nodes 39 | - UI state calculation: Compute view states from multiple data sources 40 | - Performance optimization: Only update when computed values actually change 41 | - Conditional dependencies: Dynamically track different nodes based on conditions 42 | 43 | - Parameter applier: Closure that computes the projected value by accessing nodes 44 | - Parameter onChange: Handler called with the filtered projected value 45 | - Parameter isolation: Actor isolation context for execution 46 | 47 | - Important: This variant automatically uses `DistinctFilter`, only triggering for distinct values 48 | */ 49 | public func withGraphTrackingMap( 50 | _ applier: @escaping () -> Projection, 51 | onChange: @escaping (Projection) -> Void, 52 | isolation: isolated (any Actor)? = #isolation 53 | ) where Projection : Equatable { 54 | withGraphTrackingMap(applier, filter: DistinctFilter(), onChange: onChange) 55 | } 56 | 57 | /** 58 | Tracks graph nodes accessed during projection with custom filtering. 59 | 60 | This is the fully customizable variant of `withGraphTrackingMap` that allows you to specify 61 | a custom filter for controlling when `onChange` is triggered. This is useful when you need 62 | filtering logic beyond simple equality comparison. 63 | 64 | ## Custom Filter Example 65 | ```swift 66 | // Only notify on significant percentage changes 67 | struct SignificantChangeFilter: Filter { 68 | private var lastValue: Double? 69 | private let threshold: Double = 0.1 // 10% change 70 | 71 | mutating func send(value: Double) -> Double? { 72 | guard let last = lastValue else { 73 | lastValue = value 74 | return value 75 | } 76 | let change = abs((value - last) / last) 77 | if change >= threshold { 78 | lastValue = value 79 | return value 80 | } 81 | return nil 82 | } 83 | } 84 | 85 | let progress = Stored(wrappedValue: 0.0) 86 | 87 | withGraphTracking { 88 | withGraphTrackingMap( 89 | { progress.wrappedValue }, 90 | filter: SignificantChangeFilter() 91 | ) { value in 92 | print("Significant progress change: \(value)") 93 | } 94 | } 95 | 96 | progress.wrappedValue = 0.05 // ✗ Not significant (< 10%) 97 | progress.wrappedValue = 0.15 // ✓ Significant (>= 10%) 98 | ``` 99 | 100 | ## Advanced Use Case: Debouncing 101 | ```swift 102 | struct DebounceFilter: Filter { 103 | private var lastTime: Date? 104 | private let interval: TimeInterval 105 | 106 | mutating func send(value: Value) -> Value? { 107 | let now = Date() 108 | if let last = lastTime, now.timeIntervalSince(last) < interval { 109 | return nil // Suppress rapid changes 110 | } 111 | lastTime = now 112 | return value 113 | } 114 | } 115 | ``` 116 | 117 | - Parameter applier: Closure that computes the projected value by accessing nodes 118 | - Parameter filter: Custom filter to control when onChange is triggered 119 | - Parameter onChange: Handler called with the filtered projected value 120 | - Parameter isolation: Actor isolation context for execution 121 | */ 122 | public func withGraphTrackingMap( 123 | _ applier: @escaping () -> Projection, 124 | filter: consuming some Filter, 125 | onChange: @escaping (Projection) -> Void, 126 | isolation: isolated (any Actor)? = #isolation 127 | ) { 128 | 129 | guard ThreadLocal.subscriptions.value != nil else { 130 | assertionFailure("You must call withGraphTracking before calling this method.") 131 | return 132 | } 133 | 134 | var filter = filter 135 | 136 | typealias Handler = () -> Void 137 | let _handlerBox = OSAllocatedUnfairLock(uncheckedState: { 138 | let result = applier() 139 | let filtered = filter.send(value: result) 140 | if let filtered { 141 | onChange(filtered) 142 | } 143 | }) 144 | 145 | withContinuousStateGraphTracking( 146 | apply: { 147 | _handlerBox.withLock { $0?() } 148 | }, 149 | didChange: { 150 | guard !_handlerBox.withLock({ $0 == nil }) else { return .stop } 151 | return .next 152 | }, 153 | isolation: isolation 154 | ) 155 | 156 | let cancellabe = AnyCancellable { 157 | _handlerBox.withLock { $0 = nil } 158 | } 159 | 160 | ThreadLocal.subscriptions.value!.append(cancellabe) 161 | 162 | } 163 | 164 | /** 165 | Tracks graph nodes accessed during dependency projection with automatic distinct filtering. 166 | 167 | This variant automatically uses `DistinctFilter` for Equatable projections, only triggering 168 | onChange when the projected value actually changes. 169 | 170 | ## Example 171 | ```swift 172 | class ViewModel { 173 | let node = Stored(wrappedValue: 42) 174 | } 175 | 176 | let viewModel = ViewModel() 177 | 178 | withGraphTracking { 179 | withGraphTrackingMap( 180 | from: viewModel, 181 | map: { vm in vm.node.wrappedValue } 182 | ) { value in 183 | print("Value changed: \(value)") 184 | } 185 | } 186 | ``` 187 | 188 | - Parameter from: The dependency object to observe (held weakly) 189 | - Parameter map: Closure that projects a value from the dependency 190 | - Parameter onChange: Handler called when the projected value changes 191 | - Parameter isolation: Actor isolation context for execution 192 | 193 | - Note: Tracking automatically stops when the dependency is deallocated 194 | */ 195 | public func withGraphTrackingMap( 196 | from: Dependency, 197 | map: @escaping (Dependency) -> Projection, 198 | onChange: @escaping (Projection) -> Void, 199 | isolation: isolated (any Actor)? = #isolation 200 | ) where Projection: Equatable { 201 | withGraphTrackingMap( 202 | from: from, 203 | map: map, 204 | filter: DistinctFilter(), 205 | onChange: onChange, 206 | isolation: isolation 207 | ) 208 | } 209 | 210 | /** 211 | Tracks graph nodes accessed during dependency projection with custom filtering. 212 | 213 | This function enables reactive processing where you project (compute) a derived value from 214 | nodes accessed through a dependency object. The dependency is held weakly, and tracking 215 | automatically stops when the dependency is deallocated. 216 | 217 | ## Behavior 218 | - Must be called within a `withGraphTracking` scope 219 | - The `map` closure is only executed when the dependency exists 220 | - Only nodes accessed during `map` execution are tracked 221 | - Tracking stops automatically when the dependency is deallocated 222 | - The projected value is passed through the provided filter 223 | - `onChange` is only called when the filtered value passes through 224 | 225 | ## Example with Custom Filter 226 | ```swift 227 | struct ThresholdFilter: Filter { 228 | private var lastValue: Int? 229 | private let threshold: Int = 5 230 | 231 | mutating func send(value: Int) -> Int? { 232 | guard let last = lastValue else { 233 | lastValue = value 234 | return value 235 | } 236 | if abs(value - last) >= threshold { 237 | lastValue = value 238 | return value 239 | } 240 | return nil 241 | } 242 | } 243 | 244 | class ViewModel { 245 | let counter = Stored(wrappedValue: 0) 246 | } 247 | 248 | let viewModel = ViewModel() 249 | 250 | withGraphTracking { 251 | withGraphTrackingMap( 252 | from: viewModel, 253 | map: { vm in vm.counter.wrappedValue }, 254 | filter: ThresholdFilter() 255 | ) { value in 256 | print("Significant change: \(value)") 257 | } 258 | } 259 | ``` 260 | 261 | - Parameter from: The dependency object to observe (held weakly) 262 | - Parameter map: Closure that projects a value from the dependency 263 | - Parameter filter: Custom filter to control when onChange is triggered 264 | - Parameter onChange: Handler called with the filtered projected value 265 | - Parameter isolation: Actor isolation context for execution 266 | 267 | - Note: Tracking automatically stops when the dependency is deallocated 268 | */ 269 | public func withGraphTrackingMap( 270 | from: Dependency, 271 | map: @escaping (Dependency) -> Projection, 272 | filter: consuming some Filter, 273 | onChange: @escaping (Projection) -> Void, 274 | isolation: isolated (any Actor)? = #isolation 275 | ) { 276 | 277 | guard ThreadLocal.subscriptions.value != nil else { 278 | assertionFailure("You must call withGraphTracking before calling this method.") 279 | return 280 | } 281 | 282 | weak var weakDependency = from 283 | 284 | var filter = filter 285 | 286 | typealias Handler = () -> Void 287 | let _handlerBox = OSAllocatedUnfairLock(uncheckedState: { 288 | guard let dependency = weakDependency else { 289 | return 290 | } 291 | let result = map(dependency) 292 | let filtered = filter.send(value: result) 293 | if let filtered { 294 | onChange(filtered) 295 | } 296 | }) 297 | 298 | withContinuousStateGraphTracking( 299 | apply: { 300 | guard weakDependency != nil else { 301 | _handlerBox.withLock { $0 = nil } 302 | return 303 | } 304 | _handlerBox.withLock { $0?() } 305 | }, 306 | didChange: { 307 | guard !_handlerBox.withLock({ $0 == nil }) else { return .stop } 308 | guard weakDependency != nil else { return .stop } 309 | return .next 310 | }, 311 | isolation: isolation 312 | ) 313 | 314 | let cancellable = AnyCancellable { 315 | _handlerBox.withLock { $0 = nil } 316 | } 317 | 318 | ThreadLocal.subscriptions.value!.append(cancellable) 319 | } 320 | --------------------------------------------------------------------------------