Skip to content

Commit 40053cd

Browse files
authored
Merge pull request #38 from connor-ricks/task/shared-state
✨ Adds SharedState.
2 parents 58592bd + d74cef4 commit 40053cd

File tree

9 files changed

+380
-0
lines changed

9 files changed

+380
-0
lines changed

.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1520"
4+
version = "1.7">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES">
8+
<BuildActionEntries>
9+
<BuildActionEntry
10+
buildForTesting = "YES"
11+
buildForRunning = "YES"
12+
buildForProfiling = "YES"
13+
buildForArchiving = "YES"
14+
buildForAnalyzing = "YES">
15+
<BuildableReference
16+
BuildableIdentifier = "primary"
17+
BlueprintIdentifier = "SharedState"
18+
BuildableName = "SharedState"
19+
BlueprintName = "SharedState"
20+
ReferencedContainer = "container:">
21+
</BuildableReference>
22+
</BuildActionEntry>
23+
</BuildActionEntries>
24+
</BuildAction>
25+
<TestAction
26+
buildConfiguration = "Debug"
27+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29+
shouldUseLaunchSchemeArgsEnv = "YES">
30+
<TestPlans>
31+
<TestPlanReference
32+
reference = "container:Tests/SharedStateTests/SharedState.xctestplan"
33+
default = "YES">
34+
</TestPlanReference>
35+
</TestPlans>
36+
</TestAction>
37+
<LaunchAction
38+
buildConfiguration = "Debug"
39+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
40+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
41+
launchStyle = "0"
42+
useCustomWorkingDirectory = "NO"
43+
ignoresPersistentStateOnLaunch = "NO"
44+
debugDocumentVersioning = "YES"
45+
debugServiceExtension = "internal"
46+
allowLocationSimulation = "YES">
47+
</LaunchAction>
48+
<ProfileAction
49+
buildConfiguration = "Release"
50+
shouldUseLaunchSchemeArgsEnv = "YES"
51+
savedToolIdentifier = ""
52+
useCustomWorkingDirectory = "NO"
53+
debugDocumentVersioning = "YES">
54+
<MacroExpansion>
55+
<BuildableReference
56+
BuildableIdentifier = "primary"
57+
BlueprintIdentifier = "SharedState"
58+
BuildableName = "SharedState"
59+
BlueprintName = "SharedState"
60+
ReferencedContainer = "container:">
61+
</BuildableReference>
62+
</MacroExpansion>
63+
</ProfileAction>
64+
<AnalyzeAction
65+
buildConfiguration = "Debug">
66+
</AnalyzeAction>
67+
<ArchiveAction
68+
buildConfiguration = "Release"
69+
revealArchiveInOrganizer = "YES">
70+
</ArchiveAction>
71+
</Scheme>

.swiftpm/xcode/xcshareddata/xcschemes/swift-nibbles-Package.xcscheme

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@
7676
ReferencedContainer = "container:">
7777
</BuildableReference>
7878
</BuildActionEntry>
79+
<BuildActionEntry
80+
buildForTesting = "YES"
81+
buildForRunning = "YES"
82+
buildForProfiling = "YES"
83+
buildForArchiving = "YES"
84+
buildForAnalyzing = "YES">
85+
<BuildableReference
86+
BuildableIdentifier = "primary"
87+
BlueprintIdentifier = "SharedState"
88+
BuildableName = "SharedState"
89+
BlueprintName = "SharedState"
90+
ReferencedContainer = "container:">
91+
</BuildableReference>
92+
</BuildActionEntry>
7993
</BuildActionEntries>
8094
</BuildAction>
8195
<TestAction
@@ -100,6 +114,16 @@
100114
ReferencedContainer = "container:">
101115
</BuildableReference>
102116
</TestableReference>
117+
<TestableReference
118+
skipped = "NO">
119+
<BuildableReference
120+
BuildableIdentifier = "primary"
121+
BlueprintIdentifier = "SharedStateTests"
122+
BuildableName = "SharedStateTests"
123+
BlueprintName = "SharedStateTests"
124+
ReferencedContainer = "container:">
125+
</BuildableReference>
126+
</TestableReference>
103127
</Testables>
104128
</TestAction>
105129
<LaunchAction

Package.resolved

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@ let package = Package(
1515
.library(name: "Extensions", targets: ["Extensions"]),
1616
.library(name: "Fuse", targets: ["Fuse"]),
1717
.library(name: "Identified", targets: ["Identified"]),
18+
.library(name: "SharedState", targets: ["SharedState"]),
1819
.library(name: "Stash", targets: ["Stash"]),
1920
.plugin(name: "Create TCA Feature", targets: ["Create TCA Feature"])
2021
],
22+
dependencies: [
23+
.package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"),
24+
],
2125
targets: [
2226
.target(name: "Exchange"),
2327
.testTarget(name: "ExchangeTests", dependencies: ["Exchange"]),
@@ -31,6 +35,9 @@ let package = Package(
3135
.target(name: "Identified"),
3236
.testTarget(name: "IdentifiedTests", dependencies: ["Identified"]),
3337

38+
.target(name: "SharedState", dependencies: ["Fuse", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),]),
39+
.testTarget(name: "SharedStateTests", dependencies: ["SharedState"]),
40+
3441
.target(name: "Stash"),
3542
.testTarget(name: "StashTests", dependencies: ["Stash"]),
3643

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//
2+
// MIT License
3+
//
4+
// Copyright (c) 2024 Connor Ricks
5+
//
6+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7+
// of this software and associated documentation files (the "Software"), to deal
8+
// in the Software without restriction, including without limitation the rights
9+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
// copies of the Software, and to permit persons to whom the Software is
11+
// furnished to do so, subject to the following conditions:
12+
//
13+
// The above copyright notice and this permission notice shall be included in all
14+
// copies or substantial portions of the Software.
15+
//
16+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
// SOFTWARE.
23+
24+
import Combine
25+
import ConcurrencyExtras
26+
import Foundation
27+
import Fuse
28+
29+
public typealias SharedStateUpdateBlock<T> = (_ old: T, _ new: T) async -> Void
30+
31+
32+
/// A box that encapsulates an object allowing others to subscribe to and monitor changes to the state.
33+
///
34+
/// Useful when you have multiple services or features that wish to subscribe to changes.
35+
///
36+
/// Frequently use in PointFree's TCA architecture to subsribe long-running effects to shared state changes.
37+
///
38+
/// ```
39+
/// let settings = SharedState(Settings(theme: .dark), onChange: { old, new in
40+
/// datastore.saveTheme(new)
41+
/// })
42+
///
43+
/// for await theme in settings {
44+
/// update(for settings)
45+
/// }
46+
///
47+
/// for await theme in settings[stream: \.theme] {
48+
/// update(for theme)
49+
/// }
50+
/// ```
51+
@dynamicMemberLookup
52+
public struct SharedState<T: Equatable & Sendable>: @unchecked Sendable {
53+
54+
// MARK: Properties
55+
56+
private let state: LockIsolated<T>
57+
private let subject = PassthroughSubject<T, Never>()
58+
private let onChange: SharedStateUpdateBlock<T>?
59+
60+
// MARK: Initializers
61+
62+
public init(_ value: T, onChange: SharedStateUpdateBlock<T>? = nil) {
63+
self.state = LockIsolated(value)
64+
self.onChange = onChange
65+
}
66+
67+
// MARK: Stream
68+
69+
public subscript<Value>(dynamicMember keyPath: KeyPath<T, Value>) -> Value {
70+
self()[keyPath: keyPath]
71+
}
72+
73+
public subscript<Value: Equatable>(
74+
stream keyPath: KeyPath<T, Value>,
75+
bufferingPolicy: AsyncStream<Value>.Continuation.BufferingPolicy = .unbounded
76+
) -> AsyncStream<Value> {
77+
78+
return subject
79+
.map(keyPath)
80+
.filter { state.value[keyPath: keyPath] != $0 }
81+
.values(bufferingPolicy: bufferingPolicy)
82+
.eraseToStream()
83+
}
84+
85+
// MARK: Publisher
86+
87+
public subscript<Value: Equatable>(
88+
publisher keyPath: KeyPath<T, Value>
89+
) -> AnyPublisher<Value, Never> {
90+
return subject
91+
.map(keyPath)
92+
.filter { state.value[keyPath: keyPath] != $0 }
93+
.eraseToAnyPublisher()
94+
}
95+
96+
public var publisher: AnyPublisher<T, Never> {
97+
subject.eraseToAnyPublisher()
98+
}
99+
100+
// MARK: Methods
101+
102+
public func callAsFunction() -> T {
103+
state.value
104+
}
105+
106+
private func set(_ newValue: T) async {
107+
let oldValue = state.value
108+
state.withValue {
109+
$0 = newValue
110+
subject.send(newValue)
111+
}
112+
await onChange?(oldValue, newValue)
113+
}
114+
115+
public func stream(bufferingPolicy: AsyncStream<T>.Continuation.BufferingPolicy = .unbounded) -> AsyncStream<T> {
116+
subject
117+
.filter { state.value != $0 }
118+
.values(bufferingPolicy: bufferingPolicy).eraseToStream()
119+
}
120+
121+
public func mutate(_ operation: (inout T) async throws -> Void) async rethrows {
122+
var value = self()
123+
try await operation(&value)
124+
await set(value)
125+
// Yielding in order to allow subscribers time to react before proceeding onwards.
126+
await Task.yield()
127+
}
128+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"configurations" : [
3+
{
4+
"id" : "3AD1685E-93AE-4C99-B1DD-6BFF0C4A1EC1",
5+
"name" : "Configuration 1",
6+
"options" : {
7+
8+
}
9+
}
10+
],
11+
"defaultOptions" : {
12+
13+
},
14+
"testTargets" : [
15+
{
16+
"parallelizable" : true,
17+
"target" : {
18+
"containerPath" : "container:",
19+
"identifier" : "SharedStateTests",
20+
"name" : "SharedStateTests"
21+
}
22+
}
23+
],
24+
"version" : 1
25+
}

0 commit comments

Comments
 (0)