From d74cef4de5941a367024d54ee2c680707dafc714 Mon Sep 17 00:00:00 2001 From: Connor Ricks Date: Mon, 12 Feb 2024 17:47:54 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=20Adds=20SharedState.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contents.xcworkspacedata | 7 + .../xcschemes/SharedState.xcscheme | 71 ++++++++++ .../xcschemes/swift-nibbles-Package.xcscheme | 24 ++++ Package.resolved | 14 ++ Package.swift | 7 + Sources/SharedState/SharedState.swift | 128 ++++++++++++++++++ Tests/SharedStateTests/SharedState.xctestplan | 25 ++++ Tests/SharedStateTests/SharedStateTests.swift | 96 +++++++++++++ Tests/swift-nibbles-Package.xctestplan | 8 ++ 9 files changed, 380 insertions(+) create mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/SharedState.xcscheme create mode 100644 Package.resolved create mode 100644 Sources/SharedState/SharedState.swift create mode 100644 Tests/SharedStateTests/SharedState.xctestplan create mode 100644 Tests/SharedStateTests/SharedStateTests.swift diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SharedState.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SharedState.xcscheme new file mode 100644 index 0000000..49d0303 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SharedState.xcscheme @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/swift-nibbles-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/swift-nibbles-Package.xcscheme index 563ae5a..5c5a9fd 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/swift-nibbles-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/swift-nibbles-Package.xcscheme @@ -76,6 +76,20 @@ ReferencedContainer = "container:"> + + + + + + + + = (_ 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: @unchecked Sendable { + + // MARK: Properties + + private let state: LockIsolated + private let subject = PassthroughSubject() + private let onChange: SharedStateUpdateBlock? + + // MARK: Initializers + + public init(_ value: T, onChange: SharedStateUpdateBlock? = nil) { + self.state = LockIsolated(value) + self.onChange = onChange + } + + // MARK: Stream + + public subscript(dynamicMember keyPath: KeyPath) -> Value { + self()[keyPath: keyPath] + } + + public subscript( + stream keyPath: KeyPath, + bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded + ) -> AsyncStream { + + return subject + .map(keyPath) + .filter { state.value[keyPath: keyPath] != $0 } + .values(bufferingPolicy: bufferingPolicy) + .eraseToStream() + } + + // MARK: Publisher + + public subscript( + publisher keyPath: KeyPath + ) -> AnyPublisher { + return subject + .map(keyPath) + .filter { state.value[keyPath: keyPath] != $0 } + .eraseToAnyPublisher() + } + + public var publisher: AnyPublisher { + 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.Continuation.BufferingPolicy = .unbounded) -> AsyncStream { + 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() + } +} diff --git a/Tests/SharedStateTests/SharedState.xctestplan b/Tests/SharedStateTests/SharedState.xctestplan new file mode 100644 index 0000000..14393b7 --- /dev/null +++ b/Tests/SharedStateTests/SharedState.xctestplan @@ -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 +} diff --git a/Tests/SharedStateTests/SharedStateTests.swift b/Tests/SharedStateTests/SharedStateTests.swift new file mode 100644 index 0000000..7143adc --- /dev/null +++ b/Tests/SharedStateTests/SharedStateTests.swift @@ -0,0 +1,96 @@ +// 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 ConcurrencyExtras +@testable import SharedState +import XCTest + +class SharedStateTests: XCTestCase { + func test_sharedState_mutate_doesUpdateAndNotify() async throws { + struct Container: Equatable { var num: Int } + let onChangeExpectation = expectation(description: "Expected onChange to be called.") + let publisherExpectation = expectation(description: "Expected publisher to be triggered.") + let publisherKeyPathExpectation = expectation(description: "Expected publisher keypath to be triggered.") + let streamExpectation = expectation(description: "Expected stream to be triggered.") + let streamKeyPathExpectation = expectation(description: "Expected stream keypath to be triggered.") + + await withMainSerialExecutor { + let sharedState = SharedState(Container(num: 10), onChange: { old, new in + XCTAssertEqual(old.num, 10) + XCTAssertEqual(new.num, 5) + onChangeExpectation.fulfill() + }) + + // Publisher Subscription + let cancellable = sharedState.publisher.sink { container in + XCTAssertEqual(container.num, 5) + publisherExpectation.fulfill() + } + + // Publisher Keypath Subscription + let cancellableKeyPath = sharedState[publisher: \.num].sink { num in + XCTAssertEqual(num, 5) + publisherKeyPathExpectation.fulfill() + } + + // Streaming Subscription + Task { + for await container in sharedState.stream(bufferingPolicy: .unbounded) { + XCTAssertEqual(container.num, 5) + streamExpectation.fulfill() + return + } + } + + // Streaming Keypath Subscription + Task { + for await num in sharedState[stream: \.num] { + XCTAssertEqual(num, 5) + streamKeyPathExpectation.fulfill() + return + } + } + + XCTAssertEqual(sharedState(), .init(num: 10)) + XCTAssertEqual(sharedState.num, 10) + XCTAssertEqual(sharedState[keyPath: \.num], 10) + + await sharedState.mutate { + await Task.yield() + $0.num = 5 + } + + XCTAssertEqual(sharedState(), .init(num: 5)) + XCTAssertEqual(sharedState.num, 5) + XCTAssertEqual(sharedState[keyPath: \.num], 5) + + await fulfillment(of: [ + onChangeExpectation, + publisherExpectation, + publisherKeyPathExpectation, + streamExpectation, + streamKeyPathExpectation, + ]) + } + } + +} diff --git a/Tests/swift-nibbles-Package.xctestplan b/Tests/swift-nibbles-Package.xctestplan index e0f2386..4e8c409 100644 --- a/Tests/swift-nibbles-Package.xctestplan +++ b/Tests/swift-nibbles-Package.xctestplan @@ -53,6 +53,14 @@ "identifier" : "ExchangeTests", "name" : "ExchangeTests" } + }, + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:", + "identifier" : "SharedStateTests", + "name" : "SharedStateTests" + } } ], "version" : 1