Skip to content

Commit

Permalink
✨ Adds SharedState.
Browse files Browse the repository at this point in the history
  • Loading branch information
connor-ricks committed Feb 12, 2024
1 parent 58592bd commit d74cef4
Show file tree
Hide file tree
Showing 9 changed files with 380 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 71 additions & 0 deletions .swiftpm/xcode/xcshareddata/xcschemes/SharedState.xcscheme
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1520"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SharedState"
BuildableName = "SharedState"
BlueprintName = "SharedState"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:Tests/SharedStateTests/SharedState.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SharedState"
BuildableName = "SharedState"
BlueprintName = "SharedState"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SharedState"
BuildableName = "SharedState"
BlueprintName = "SharedState"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
Expand All @@ -100,6 +114,16 @@
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SharedStateTests"
BuildableName = "SharedStateTests"
BlueprintName = "SharedStateTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
14 changes: 14 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71",
"version" : "1.1.0"
}
}
],
"version" : 2
}
7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ let package = Package(
.library(name: "Extensions", targets: ["Extensions"]),
.library(name: "Fuse", targets: ["Fuse"]),
.library(name: "Identified", targets: ["Identified"]),
.library(name: "SharedState", targets: ["SharedState"]),
.library(name: "Stash", targets: ["Stash"]),
.plugin(name: "Create TCA Feature", targets: ["Create TCA Feature"])
],
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"),
],
targets: [
.target(name: "Exchange"),
.testTarget(name: "ExchangeTests", dependencies: ["Exchange"]),
Expand All @@ -31,6 +35,9 @@ let package = Package(
.target(name: "Identified"),
.testTarget(name: "IdentifiedTests", dependencies: ["Identified"]),

.target(name: "SharedState", dependencies: ["Fuse", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),]),
.testTarget(name: "SharedStateTests", dependencies: ["SharedState"]),

.target(name: "Stash"),
.testTarget(name: "StashTests", dependencies: ["Stash"]),

Expand Down
128 changes: 128 additions & 0 deletions Sources/SharedState/SharedState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//
// MIT License
//
// Copyright (c) 2024 Connor Ricks
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import Combine
import ConcurrencyExtras
import Foundation
import Fuse

public typealias SharedStateUpdateBlock<T> = (_ old: T, _ new: T) async -> Void


/// A box that encapsulates an object allowing others to subscribe to and monitor changes to the state.
///
/// Useful when you have multiple services or features that wish to subscribe to changes.
///
/// Frequently use in PointFree's TCA architecture to subsribe long-running effects to shared state changes.
///
/// ```
/// let settings = SharedState(Settings(theme: .dark), onChange: { old, new in
/// datastore.saveTheme(new)
/// })
///
/// for await theme in settings {
/// update(for settings)
/// }
///
/// for await theme in settings[stream: \.theme] {
/// update(for theme)
/// }
/// ```
@dynamicMemberLookup
public struct SharedState<T: Equatable & Sendable>: @unchecked Sendable {

// MARK: Properties

private let state: LockIsolated<T>
private let subject = PassthroughSubject<T, Never>()
private let onChange: SharedStateUpdateBlock<T>?

// MARK: Initializers

public init(_ value: T, onChange: SharedStateUpdateBlock<T>? = nil) {
self.state = LockIsolated(value)
self.onChange = onChange
}

// MARK: Stream

public subscript<Value>(dynamicMember keyPath: KeyPath<T, Value>) -> Value {
self()[keyPath: keyPath]
}

public subscript<Value: Equatable>(
stream keyPath: KeyPath<T, Value>,
bufferingPolicy: AsyncStream<Value>.Continuation.BufferingPolicy = .unbounded
) -> AsyncStream<Value> {

return subject
.map(keyPath)
.filter { state.value[keyPath: keyPath] != $0 }
.values(bufferingPolicy: bufferingPolicy)
.eraseToStream()
}

// MARK: Publisher

public subscript<Value: Equatable>(
publisher keyPath: KeyPath<T, Value>
) -> AnyPublisher<Value, Never> {
return subject
.map(keyPath)
.filter { state.value[keyPath: keyPath] != $0 }
.eraseToAnyPublisher()
}

public var publisher: AnyPublisher<T, Never> {
subject.eraseToAnyPublisher()
}

// MARK: Methods

public func callAsFunction() -> T {
state.value
}

private func set(_ newValue: T) async {
let oldValue = state.value
state.withValue {
$0 = newValue
subject.send(newValue)
}
await onChange?(oldValue, newValue)
}

public func stream(bufferingPolicy: AsyncStream<T>.Continuation.BufferingPolicy = .unbounded) -> AsyncStream<T> {
subject
.filter { state.value != $0 }
.values(bufferingPolicy: bufferingPolicy).eraseToStream()
}

public func mutate(_ operation: (inout T) async throws -> Void) async rethrows {
var value = self()
try await operation(&value)
await set(value)
// Yielding in order to allow subscribers time to react before proceeding onwards.
await Task.yield()
}
}
25 changes: 25 additions & 0 deletions Tests/SharedStateTests/SharedState.xctestplan
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"configurations" : [
{
"id" : "3AD1685E-93AE-4C99-B1DD-6BFF0C4A1EC1",
"name" : "Configuration 1",
"options" : {

}
}
],
"defaultOptions" : {

},
"testTargets" : [
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:",
"identifier" : "SharedStateTests",
"name" : "SharedStateTests"
}
}
],
"version" : 1
}

0 comments on commit d74cef4

Please sign in to comment.