├── .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 |
--------------------------------------------------------------------------------