diff --git a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj index 0b7b7e7f967d..15e740e9367d 100644 --- a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj +++ b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ CAA9ADCC2446615B0003A984 /* 03-Effects-LongLivingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADCB2446615B0003A984 /* 03-Effects-LongLivingTests.swift */; }; CABC4F3926AEE00C00D5FA2C /* 03-Effects-Refreshable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3826AEE00C00D5FA2C /* 03-Effects-Refreshable.swift */; }; CABC4F3B26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3A26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift */; }; + CACA7FBC2BC707F2002DF110 /* 02-SharedState-Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = CACA7FBB2BC707F2002DF110 /* 02-SharedState-Notifications.swift */; }; CADECDB62B5CA228009DC881 /* 02-SharedState-InMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDB52B5CA228009DC881 /* 02-SharedState-InMemory.swift */; }; CADECDB82B5CA425009DC881 /* 02-SharedState-FileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDB72B5CA425009DC881 /* 02-SharedState-FileStorage.swift */; }; CADECDBA2B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDB92B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift */; }; @@ -179,6 +180,7 @@ CAA9ADCB2446615B0003A984 /* 03-Effects-LongLivingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-LongLivingTests.swift"; sourceTree = ""; }; CABC4F3826AEE00C00D5FA2C /* 03-Effects-Refreshable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-Refreshable.swift"; sourceTree = ""; }; CABC4F3A26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-RefreshableTests.swift"; sourceTree = ""; }; + CACA7FBB2BC707F2002DF110 /* 02-SharedState-Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-Notifications.swift"; sourceTree = ""; }; CADECDB52B5CA228009DC881 /* 02-SharedState-InMemory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-InMemory.swift"; sourceTree = ""; }; CADECDB72B5CA425009DC881 /* 02-SharedState-FileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-FileStorage.swift"; sourceTree = ""; }; CADECDB92B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-GettingStarted-SharedStateUserDefaultsTests.swift"; sourceTree = ""; }; @@ -407,6 +409,7 @@ DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */, CADECDB72B5CA425009DC881 /* 02-SharedState-FileStorage.swift */, CADECDB52B5CA228009DC881 /* 02-SharedState-InMemory.swift */, + CACA7FBB2BC707F2002DF110 /* 02-SharedState-Notifications.swift */, CADECDBF2B5DE7C1009DC881 /* 02-SharedState-Onboarding.swift */, CAA0CC3D2B8D3B4A00D7AF54 /* 02-SharedState-Sandboxing.swift */, CA7BC8ED245CCFE4001FB69F /* 02-SharedState-UserDefaults.swift */, @@ -751,6 +754,7 @@ DCC68EE12447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift in Sources */, DC072322244663B1003A8B65 /* 04-Navigation-Sheet-LoadThenPresent.swift in Sources */, DC89C45324465452006900B9 /* 04-Navigation-Lists-NavigateAndLoad.swift in Sources */, + CACA7FBC2BC707F2002DF110 /* 02-SharedState-Notifications.swift in Sources */, DCC68EE32447C8540037F998 /* 05-HigherOrderReducers-ReusableFavoriting.swift in Sources */, CA3E421F26B8337500581ABC /* 01-GettingStarted-FocusState.swift in Sources */, DCC68EDF2447BC810037F998 /* TemplateText.swift in Sources */, diff --git a/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift b/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift index 4c9f63e8d58d..4870edc8fc42 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift @@ -90,6 +90,15 @@ struct RootView: View { SharedStateSandboxingView(store: store) } } + NavigationLink("Notifications") { + Demo( + store: Store( + initialState: SharedStateNotifications.State() + ) { SharedStateNotifications() } + ) { store in + SharedStateNotificationsView(store: store) + } + } Button("Sign up flow") { isSignUpCaseStudyPresented = true } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-FileStorage.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-FileStorage.swift index f8719d902650..d4e19cd411c5 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-FileStorage.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-FileStorage.swift @@ -223,7 +223,7 @@ struct Stats: Codable, Equatable { } } -extension PersistenceKey where Self == FileStorageKey { +extension PersistenceReaderKey where Self == FileStorageKey { fileprivate static var stats: Self { fileStorage(.documentsDirectory.appending(path: "stats.json")) } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-InMemory.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-InMemory.swift index 4d746f4a394e..0b313d709a91 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-InMemory.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-InMemory.swift @@ -211,7 +211,7 @@ private struct ProfileTabView: View { ) } -extension PersistenceKey where Self == InMemoryKey { +extension PersistenceReaderKey where Self == InMemoryKey { fileprivate static var stats: Self { inMemory("stats") } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Notifications.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Notifications.swift new file mode 100644 index 000000000000..ec24d435481b --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Notifications.swift @@ -0,0 +1,130 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This application demonstrates how to use the `@SharedReader` tool to introduce a piece of \ + read-only state to your feature whose true value lives in an external system. In this case, \ + the state is the number of times a screenshot is taken, which is counted from the \ + `userDidTakeScreenshotNotification` notification. + + Run this application in the simulator, and take a few screenshots by going to \ + *Device › Trigger Screenshot* in the menu, and observe that the UI counts the number of times \ + that happens. + + The `@SharedReader` state will update automatically when the screenshot notification is posted \ + by the system, and further you can use the `.publisher` property on `@SharedReader` to listen \ + for any changes to the data. + """ + +@Reducer +struct SharedStateNotifications { + @ObservableState + struct State: Equatable { + var fact: String? + @SharedReader(.screenshotCount) var screenshotCount = 0 + } + enum Action { + case factResponse(Result) + case onAppear + } + @Dependency(\.factClient) var factClient + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .factResponse(.success(fact)): + state.fact = fact + return .none + + case .factResponse(.failure): + return .none + + case .onAppear: + return .run { [screenshotCount = state.$screenshotCount] send in + for await count in screenshotCount.publisher.values { + await send(.factResponse(Result { try await factClient.fetch(count) })) + } + } + } + } + } +} + +struct SharedStateNotificationsView: View { + let store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Text("A screenshot of this screen has been taken \(store.screenshotCount) times.") + .font(.headline) + + if let fact = store.fact { + Text("\(fact)") + } + } + .navigationTitle("Long-living effects") + .task { await store.send(.onAppear).finish() } + } +} + +extension PersistenceReaderKey where Self == NotificationReaderKey { + static var screenshotCount: Self { + NotificationReaderKey( + initialValue: 0, + name: MainActor.assumeIsolated { + UIApplication.userDidTakeScreenshotNotification + } + ) { value, _ in + value += 1 + } + } +} + +struct NotificationReaderKey: PersistenceReaderKey { + let name: Notification.Name + private let transform: @Sendable (Notification) -> Value + + init( + initialValue: Value, + name: Notification.Name, + transform: @Sendable @escaping (inout Value, Notification) -> Void + ) { + self.name = name + let value = LockIsolated(initialValue) + self.transform = { notification in + value.withValue { [notification = UncheckedSendable(notification)] in + transform(&$0, notification.wrappedValue) + } + return value.value + } + } + + func load(initialValue: Value?) -> Value? { nil } + + func subscribe( + initialValue: Value?, + didSet: @Sendable @escaping (Value?) -> Void + ) -> Shared.Subscription { + let token = NotificationCenter.default.addObserver( + forName: name, + object: nil, + queue: nil, + using: { notification in + didSet(self.transform(notification)) + } + ) + return Shared.Subscription { + NotificationCenter.default.removeObserver(token) + } + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.name == rhs.name + } + func hash(into hasher: inout Hasher) { + hasher.combine(name) + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Sandboxing.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Sandboxing.swift index 9c90967b0d2c..a99edb0a7750 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Sandboxing.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Sandboxing.swift @@ -119,12 +119,12 @@ struct SharedStateSandboxingView: View { } } -extension PersistenceKey where Self == AppStorageKey { +extension PersistenceReaderKey where Self == AppStorageKey { static var appStorageCount: Self { Self("appStorageCount") } } -extension PersistenceKey where Self == FileStorageKey { +extension PersistenceReaderKey where Self == FileStorageKey { static var fileStorageCount: Self { Self(url: URL.documentsDirectory.appending(path: "fileStorageCount.json")) } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-UserDefaults.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-UserDefaults.swift index f6db68283a04..67f64fd85e5b 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-UserDefaults.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-UserDefaults.swift @@ -199,7 +199,7 @@ private struct ProfileTabView: View { } } -extension PersistenceKey where Self == AppStorageKey { +extension PersistenceReaderKey where Self == AppStorageKey { fileprivate static var count: Self { appStorage("sharedStateDemoCount") } diff --git a/Examples/SyncUps/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUps/SyncUpsList.swift index 36c5918f8768..ae72ec826be9 100644 --- a/Examples/SyncUps/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUps/SyncUpsList.swift @@ -185,7 +185,7 @@ struct SyncUpsList_Previews: PreviewProvider { ) } -extension PersistenceKey where Self == FileStorageKey> { +extension PersistenceReaderKey where Self == FileStorageKey> { static var syncUps: Self { fileStorage(.documentsDirectory.appending(component: "sync-ups.json")) } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md index db20e10f808c..cb68c69c939e 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md @@ -202,7 +202,7 @@ And then define a static function on the ``PersistenceKey`` protocol for creatin persistence strategy: ```swift -extension PersistenceKey { +extension PersistenceReaderKey { public static func custom(/*...*/) -> Self where Self == CustomPersistence { CustomPersistence(/* ... */) @@ -663,7 +663,7 @@ To add some type-safety and reusability to this process you can extend the ``Fil to add a static variable for describing the details of your persistence: ```swift -extension PersistenceKey where Self == FileStorageKey> { +extension PersistenceReaderKey where Self == FileStorageKey> { static let users: Self { fileStorage(URL(/* ... */)) } @@ -694,7 +694,7 @@ This technique works for all types of persistence strategies. For example, a typ key can be constructed like so: ```swift -extension PersistenceKey where Self == InMemoryKey> { +extension PersistenceReaderKey where Self == InMemoryKey> { static var users: Self { inMemory("users") } @@ -704,7 +704,7 @@ extension PersistenceKey where Self == InMemoryKey> { And a type-safe `.appStorage` key can be constructed like so: ```swift -extension PersistenceKey where Self == AppStorageKey { +extension PersistenceReaderKey where Self == AppStorageKey { static var count: Self { appStorage("count") } diff --git a/Sources/ComposableArchitecture/Internal/DefaultSubscript.swift b/Sources/ComposableArchitecture/Internal/DefaultSubscript.swift new file mode 100644 index 000000000000..07eeda74b068 --- /dev/null +++ b/Sources/ComposableArchitecture/Internal/DefaultSubscript.swift @@ -0,0 +1,34 @@ +final class DefaultSubscript: Hashable { + var value: Value + init(_ value: Value) { + self.value = value + } + static func == (lhs: DefaultSubscript, rhs: DefaultSubscript) -> Bool { + lhs === rhs + } + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +extension Optional { + subscript(default defaultSubscript: DefaultSubscript) -> Wrapped { + get { self ?? defaultSubscript.value } + set { + defaultSubscript.value = newValue + if self != nil { self = newValue } + } + } +} + +extension RandomAccessCollection where Self: MutableCollection { + subscript( + position: Index, default defaultSubscript: DefaultSubscript + ) -> Element { + get { self.indices.contains(position) ? self[position] : defaultSubscript.value } + set { + defaultSubscript.value = newValue + if self.indices.contains(position) { self[position] = newValue } + } + } +} diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift index 01603a41454e..26d7bef60778 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift @@ -1,36 +1,40 @@ -/// A type that can persist shared state to an external storage. -/// -/// Conform to this protocol to express persistence to some external storage by describing how to -/// save to and load from the external storage, and providing a stream of values that represents -/// when the external storage is changed from the outside. It is only necessary to conform to -/// this protocol if the ``appStorage(_:)-2ntx6``, ``fileStorage(_:)`` or ``inMemory(_:)`` -/// strategies are not sufficient for your use case. -/// -/// See the article for more information, in particular the -/// section. -public protocol PersistenceKey: Hashable { +public protocol PersistenceReaderKey: Hashable { associatedtype Value /// Loads the freshest value from storage. Returns `nil` if there is no value in storage. func load(initialValue: Value?) -> Value? // TODO: Should this be throwing? - /// Saves a value to storage. - func save(_ value: Value) - /// Subscribes to external updates. func subscribe( - initialValue: Value?, didSet: @escaping (_ newValue: Value?) -> Void + initialValue: Value?, + didSet: @Sendable @escaping (_ newValue: Value?) -> Void ) -> Shared.Subscription } -extension PersistenceKey { +extension PersistenceReaderKey { public func subscribe( - initialValue: Value?, didSet: @escaping (_ newValue: Value?) -> Void + initialValue: Value?, + didSet: @Sendable @escaping (_ newValue: Value?) -> Void ) -> Shared.Subscription { Shared.Subscription {} } } +/// A type that can persist shared state to an external storage. +/// +/// Conform to this protocol to express persistence to some external storage by describing how to +/// save to and load from the external storage, and providing a stream of values that represents +/// when the external storage is changed from the outside. It is only necessary to conform to +/// this protocol if the ``appStorage(_:)-2ntx6``, ``fileStorage(_:)`` or ``inMemory(_:)`` +/// strategies are not sufficient for your use case. +/// +/// See the article for more information, in particular the +/// section. +public protocol PersistenceKey: PersistenceReaderKey { + /// Saves a value to storage. + func save(_ value: Value) +} + extension Shared { public class Subscription { let onCancel: () -> Void diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift index ae0b0a083ecd..209b8ae9e915 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift @@ -1,7 +1,7 @@ import Dependencies import Foundation -extension PersistenceKey { +extension PersistenceReaderKey { /// Creates a persistence key that can read and write to a boolean user default. /// /// - Parameter key: The key to read and write the value to in the user defaults store. @@ -288,7 +288,8 @@ extension AppStorageKey: PersistenceKey { } public func subscribe( - initialValue: Value?, didSet: @escaping (_ newValue: Value?) -> Void + initialValue: Value?, + didSet: @Sendable @escaping (_ newValue: Value?) -> Void ) -> Shared.Subscription { let userDefaultsDidChange = NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift index 3f762f169e08..415408e0fa46 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift @@ -1,7 +1,7 @@ import Dependencies import Foundation -extension PersistenceKey { +extension PersistenceReaderKey { /// Creates a persistence key for sharing data in user defaults given a key path. /// /// For example, one could initialize a key with the date and time at which the application was @@ -47,7 +47,8 @@ extension AppStorageKeyPathKey: PersistenceKey { } public func subscribe( - initialValue: Value?, didSet: @escaping (_ newValue: Value?) -> Void + initialValue: Value?, + didSet: @Sendable @escaping (_ newValue: Value?) -> Void ) -> Shared.Subscription { let observer = self.store.observe(self.keyPath, options: .new) { _, change in guard diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift index 102104bf8712..4a65ceb1cc05 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift @@ -2,7 +2,7 @@ import Combine import Dependencies import Foundation -extension PersistenceKey { +extension PersistenceReaderKey { /// Creates a persistence key that can read and write to a `Codable` value to the file system. /// /// - Parameter url: The file URL from which to read and write the value. @@ -58,7 +58,8 @@ public final class FileStorageKey: PersistenceKey, @u } public func subscribe( - initialValue: Value?, didSet: @escaping (_ newValue: Value?) -> Void + initialValue: Value?, + didSet: @Sendable @escaping (_ newValue: Value?) -> Void ) -> Shared.Subscription { // NB: Make sure there is a file to create a source for. if !self.storage.fileExists(at: self.url) { diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift index 17c66d07b7e0..fc30cfa4ca39 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift @@ -1,7 +1,7 @@ import Dependencies import Foundation -extension PersistenceKey { +extension PersistenceReaderKey { /// Creates a persistence key for sharing data in-memory for the lifetime of an application. /// /// For example, one could initialize a key with the date and time at which the application was diff --git a/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift b/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift index cc0fbb7599e5..a1de5c57473d 100644 --- a/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift +++ b/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift @@ -58,11 +58,63 @@ extension Shared { } } +extension SharedReader { + public init( + wrappedValue value: Value, + _ persistenceKey: some PersistenceReaderKey, + fileID: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + reference: { + @Dependency(PersistentReferencesKey.self) var references + return references.withValue { + if let reference = $0[persistenceKey] { + return reference + } else { + let reference = ValueReference( + initialValue: value, + persistenceKey: persistenceKey, + fileID: fileID, + line: line + ) + $0[persistenceKey] = reference + return reference + } + } + }(), + keyPath: \Value.self + ) + } + + public init( + _ persistenceKey: some PersistenceReaderKey, + fileID: StaticString = #fileID, + line: UInt = #line + ) where Value == Wrapped? { + self.init(wrappedValue: nil, persistenceKey, fileID: fileID, line: line) + } + + public init( + _ persistenceKey: some PersistenceReaderKey, + fileID: StaticString = #fileID, + line: UInt = #line + ) throws { + guard let initialValue = persistenceKey.load(initialValue: nil) + else { + throw LoadError() + } + self.init(wrappedValue: initialValue, persistenceKey, fileID: fileID, line: line) + } +} + private struct LoadError: Error {} -final class ValueReference: Reference, @unchecked Sendable { +final class ValueReference>: Reference, @unchecked + Sendable +{ private let lock = NSRecursiveLock() - private let persistenceKey: (any PersistenceKey)? + private let persistenceKey: Persistence? #if canImport(Combine) private let subject: CurrentValueRelay #endif @@ -93,7 +145,12 @@ final class ValueReference: Reference, @unchecked Sendable { #endif self.lock.withLock { self._value = newValue - self.persistenceKey?.save(self._value) + func open(_ key: some PersistenceKey) { + key.save(self._value as! A) + } + guard let key = self.persistenceKey as? any PersistenceKey + else { return } + open(key) } } } @@ -104,7 +161,7 @@ final class ValueReference: Reference, @unchecked Sendable { #endif init( initialValue: Value, - persistenceKey: (any PersistenceKey)? = nil, + persistenceKey: Persistence? = nil, fileID: StaticString, line: UInt ) { @@ -120,7 +177,13 @@ final class ValueReference: Reference, @unchecked Sendable { initialValue: initialValue ) { [weak self] value in guard let self else { return } - self.lock.withLock { self._value = value ?? initialValue } + #if canImport(Perception) + self._$perceptionRegistrar.willSet(self, keyPath: \.value) + defer { self._$perceptionRegistrar.didSet(self, keyPath: \.value) } + #endif + self.lock.withLock { + self._value = value ?? initialValue + } } } } diff --git a/Sources/ComposableArchitecture/SharedState/Shared.swift b/Sources/ComposableArchitecture/SharedState/Shared.swift index de8f74e328ba..a1ebaf6ef8da 100644 --- a/Sources/ComposableArchitecture/SharedState/Shared.swift +++ b/Sources/ComposableArchitecture/SharedState/Shared.swift @@ -82,7 +82,11 @@ public struct Shared { public init(_ value: Value, fileID: StaticString = #fileID, line: UInt = #line) { self.init( - reference: ValueReference(initialValue: value, fileID: fileID, line: line), + reference: ValueReference>( + initialValue: value, + fileID: fileID, + line: line + ), keyPath: \Value.self ) } @@ -209,6 +213,8 @@ extension Shared: Equatable where Value: Equatable { extension Shared: Hashable where Value: Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(self.wrappedValue) + // TODO: hash reference too? + // TODO: or should we only hash reference? } } @@ -277,37 +283,30 @@ where Value: RandomAccessCollection & MutableCollection, Value.Index: Hashable & } } -extension Optional { - fileprivate subscript(default defaultSubscript: DefaultSubscript) -> Wrapped { - get { self ?? defaultSubscript.value } - set { - defaultSubscript.value = newValue - if self != nil { self = newValue } - } +extension Shared { + public subscript( + dynamicMember keyPath: KeyPath + ) -> SharedReader { + SharedReader( + reference: self.reference, + keyPath: self.keyPath.appending(path: keyPath)! + ) } -} -extension RandomAccessCollection where Self: MutableCollection { - fileprivate subscript( - position: Index, default defaultSubscript: DefaultSubscript - ) -> Element { - get { self.indices.contains(position) ? self[position] : defaultSubscript.value } - set { - defaultSubscript.value = newValue - if self.indices.contains(position) { self[position] = newValue } - } + public var reader: SharedReader { + SharedReader(reference: self.reference, keyPath: self.keyPath) } -} -private final class DefaultSubscript: Hashable { - var value: Value - init(_ value: Value) { - self.value = value - } - static func == (lhs: DefaultSubscript, rhs: DefaultSubscript) -> Bool { - lhs === rhs - } - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) + public subscript( + dynamicMember keyPath: KeyPath + ) -> SharedReader? { + guard let initialValue = self.wrappedValue[keyPath: keyPath] + else { return nil } + return SharedReader( + reference: self.reference, + keyPath: self.keyPath.appending( + path: keyPath.appending(path: \.[default:DefaultSubscript(initialValue)]) + )! + ) } } diff --git a/Sources/ComposableArchitecture/SharedState/SharedReader.swift b/Sources/ComposableArchitecture/SharedState/SharedReader.swift new file mode 100644 index 000000000000..90c3f44454e8 --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/SharedReader.swift @@ -0,0 +1,128 @@ +#if canImport(Combine) +import Combine +#endif + +@dynamicMemberLookup +@propertyWrapper +public struct SharedReader { + fileprivate let reference: any Reference + fileprivate let keyPath: AnyKeyPath + + init(reference: any Reference, keyPath: AnyKeyPath) { + self.reference = reference + self.keyPath = keyPath + } + + init(reference: some Reference) { + self.init(reference: reference, keyPath: \Value.self) + } + + public init(projectedValue: SharedReader) { + self = projectedValue + } + + public init(_ value: Value, fileID: StaticString = #fileID, line: UInt = #line) { + self.init( + reference: ValueReference>( + initialValue: value, + fileID: fileID, + line: line + ), + keyPath: \Value.self + ) + } + + public var wrappedValue: Value { + func open(_ reference: some Reference) -> Value { + reference.value[ + keyPath: unsafeDowncast(self.keyPath, to: KeyPath.self) + ] + } + return open(self.reference) + } + + public var projectedValue: Self { + get { self } + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> SharedReader { + SharedReader(reference: self.reference, keyPath: self.keyPath.appending(path: keyPath)!) + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> SharedReader? { + guard let initialValue = self.wrappedValue[keyPath: keyPath] + else { return nil } + return SharedReader( + reference: self.reference, + keyPath: self.keyPath.appending( + path: keyPath.appending(path: \.[default:DefaultSubscript(initialValue)]) + )! + ) + } + +#if canImport(Combine) + public var publisher: AnyPublisher { + func open(_ reference: R) -> AnyPublisher { + return reference.publisher + .compactMap { $0[keyPath: self.keyPath] as? Value } + .eraseToAnyPublisher() + } + return open(self.reference) + } +#endif +} + +extension SharedReader: @unchecked Sendable where Value: Sendable {} + +extension SharedReader: Equatable where Value: Equatable { + public static func == (lhs: SharedReader, rhs: SharedReader) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension SharedReader: Hashable where Value: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.wrappedValue) + } +} + +extension SharedReader: Identifiable where Value: Identifiable { + public var id: Value.ID { + self.wrappedValue.id + } +} + +extension SharedReader: Encodable where Value: Encodable { + public func encode(to encoder: Encoder) throws { + do { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } catch { + try self.wrappedValue.encode(to: encoder) + } + } +} + +extension SharedReader: CustomDumpRepresentable { + public var customDumpValue: Any { + self.wrappedValue + } +} + +extension SharedReader +where Value: RandomAccessCollection & MutableCollection, Value.Index: Hashable & Sendable { + /// Derives a collection of read-only shared elements from a read-only shared collection of + /// elements. + /// + /// See the documentation for [`@Shared`]()'s ``Shared/elements`` for more + /// information. + public var elements: some RandomAccessCollection> { + zip(self.wrappedValue.indices, self.wrappedValue).lazy.map { index, element in + self[index, default: DefaultSubscript(element)] + } + } +} diff --git a/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift b/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift index d814bc68ecfa..90a6c4b0d5d1 100644 --- a/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift +++ b/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift @@ -47,6 +47,7 @@ public struct ObservableStateMacro { static let presentsMacroName = "Presents" static let presentationStatePropertyWrapperName = "PresentationState" static let sharedPropertyWrapperName = "Shared" + static let sharedReaderPropertyWrapperName = "SharedReader" static let registrarVariableName = "_$observationRegistrar" @@ -447,6 +448,7 @@ extension ObservableStateMacro: MemberAttributeMacro { if property.hasMacroApplication(ObservableStateMacro.presentsMacroName) || property.hasMacroApplication(ObservableStateMacro.sharedPropertyWrapperName) + || property.hasMacroApplication(ObservableStateMacro.sharedReaderPropertyWrapperName) { return [ AttributeSyntax( diff --git a/Tests/ComposableArchitectureTests/AppStorageTests.swift b/Tests/ComposableArchitectureTests/AppStorageTests.swift index a44bc6682aaf..b274e0846848 100644 --- a/Tests/ComposableArchitectureTests/AppStorageTests.swift +++ b/Tests/ComposableArchitectureTests/AppStorageTests.swift @@ -75,7 +75,7 @@ final class AppStorageTests: XCTestCase { XCTAssertEqual(defaultAppStorage.integer(forKey: "count"), 0) } - func testObservation() { + func testObservation_DirectMutation() { @Shared(.appStorage("count")) var count = 0 let countDidChange = self.expectation(description: "countDidChange") withPerceptionTracking { @@ -87,6 +87,23 @@ final class AppStorageTests: XCTestCase { self.wait(for: [countDidChange], timeout: 0) } + func testObservation_ExternalMutation() { + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("count")) var count = 0 + let didChange = self.expectation(description: "didChange") + + withPerceptionTracking { + _ = count + } onChange: { [count = $count] in + XCTAssertEqual(count.wrappedValue, 0) + didChange.fulfill() + } + + defaults.setValue(42, forKey: "count") + self.wait(for: [didChange], timeout: 0) + XCTAssertEqual(count, 42) + } + func testChangeUserDefaultsDirectly() { @Dependency(\.defaultAppStorage) var defaults @Shared(.appStorage("count")) var count = 0 diff --git a/Tests/ComposableArchitectureTests/SharedReaderTests.swift b/Tests/ComposableArchitectureTests/SharedReaderTests.swift new file mode 100644 index 000000000000..4e604921ab9c --- /dev/null +++ b/Tests/ComposableArchitectureTests/SharedReaderTests.swift @@ -0,0 +1,16 @@ +import Combine +import ComposableArchitecture +import XCTest + +final class SharedReaderTests: XCTestCase { + @MainActor + func testSharedReader() { + @Shared var count: Int + _count = Shared(0) + let countReader = $count.reader + + count += 1 + XCTAssertEqual(count, 1) + XCTAssertEqual(countReader.wrappedValue, 1) + } +}