From 7c6fb2670019d61e9040fab448dfce885b56c7fd Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:28:23 -0400 Subject: [PATCH] Address @Shared sendability. (#3329) * Address @Shared sendability. * Undo UncheckedSendable. * clean up * wip * drop AnySendable. * wip * Address `Effect.throttle` sendability (#3325) * Address effect cancellation sendability (#3326) * Address effect cancellation sendability * fix * wip * wip * Separate SendableDefaultSubscript from DefaultSubscript. * fix test * drop escaping * switch on swift 6 language mode * xcode 16 * update test * wip --------- Co-authored-by: Stephen Celis --- .../xcshareddata/swiftpm/Package.resolved | 51 +++++----- .github/workflows/ci.yml | 8 +- .../SyncUpsTests/AppFeatureTests.swift | 7 +- Package.swift | 2 +- Package@swift-6.0.swift | 97 +++++++++++++++++++ .../Reducers/PresentationReducer.swift | 4 +- .../SharedState/PersistenceKey.swift | 4 +- .../PersistenceKey/AppStorageKey.swift | 52 +++++----- .../PersistenceKey/AppStorageKeyPathKey.swift | 18 ++-- .../PersistenceKey/FileStorageKey.swift | 10 +- .../PersistenceKey/InMemoryKey.swift | 2 +- .../PersistenceKeyDefault.swift | 4 +- .../SharedState/Reference.swift | 4 +- .../References/ValueReference.swift | 18 ++-- .../SharedState/Shared.swift | 47 ++++++--- .../SharedState/SharedChangeTracking.swift | 6 +- .../SharedState/SharedReader.swift | 10 +- .../EffectRunTests.swift | 2 +- .../SharedTests.swift | 4 +- 19 files changed, 237 insertions(+), 113 deletions(-) create mode 100644 Package@swift-6.0.swift diff --git a/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved b/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0eafb7847d76..7bde30387cf4 100644 --- a/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "a84dfeef15185f26bd546eb430da9b0b1f23f5a08c6ac0f99b345a8de1564068", "pins" : [ { "identity" : "combine-schedulers", @@ -32,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "71344dd930fde41e8f3adafe260adcbb2fc2a3dc", - "version" : "1.5.4" + "revision" : "642e6aab8e03e5f992d9c83e38c5be98cfad5078", + "version" : "1.5.5" } }, { @@ -41,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "3581e280bf0d90c3fb9236fb23e75a5d8c46b533", - "version" : "1.0.4" + "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", + "version" : "1.0.5" } }, { @@ -50,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" + "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", + "version" : "1.1.3" } }, { @@ -77,23 +78,23 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "d7472be6b3c89251ce4c0db07d32405b43426781", - "version" : "1.3.7" + "revision" : "3ef38bb702a1a2f39c7e19fc0578403b8ee52b17", + "version" : "1.3.9" } }, { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-plugin", + "location" : "https://github.com/swiftlang/swift-docc-plugin", "state" : { - "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", - "version" : "1.3.0" + "revision" : "2eb22993b3dfd0c0d32729b357c8dabb6cd44680", + "version" : "1.4.2" } }, { "identity" : "swift-docc-symbolkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-symbolkit", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", "state" : { "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" @@ -113,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-macro-testing", "state" : { - "revision" : "a35257b7e9ce44e92636447003a8eeefb77b145c", - "version" : "0.5.1" + "revision" : "20c1a8f3b624fb5d1503eadcaa84743050c350f4", + "version" : "0.5.2" } }, { @@ -122,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "47cfd149ed01223d14fc8e3f52ae27d3a35fe036", - "version" : "2.0.3" + "revision" : "e834b3760731160d7d448509ee6a1408c8582a6b", + "version" : "2.2.0" } }, { @@ -131,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "1552c8f722ac256cc0b8daaf1a7073217d4fcdfb", - "version" : "1.3.4" + "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", + "version" : "1.3.5" } }, { @@ -140,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "c097f955b4e724690f0fc8ffb7a6d4b881c9c4e3", - "version" : "1.17.2" + "revision" : "6d932a79e7173b275b96c600c86c603cf84f153c", + "version" : "1.17.4" } }, { @@ -149,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", - "version" : "600.0.0-prerelease-2024-06-12" + "revision" : "515f79b522918f83483068d99c68daeb5116342d", + "version" : "600.0.0-prerelease-2024-09-04" } }, { @@ -158,10 +159,10 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", - "version" : "1.2.2" + "revision" : "96beb108a57f24c8476ae1f309239270772b2940", + "version" : "1.2.5" } } ], - "version" : 2 + "version" : 3 } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb5cb7574d00..c766037e1be1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,16 +19,20 @@ jobs: runs-on: macos-14 strategy: matrix: + xcode: + - 15.4 + - 16_beta_6 config: - debug - release steps: - uses: actions/checkout@v4 - - name: Select Xcode 15.4 - run: sudo xcode-select -s /Applications/Xcode_15.4.app + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: Build ${{ matrix.config }} run: make CONFIG=${{ matrix.config }} build-all-platforms - name: Run ${{ matrix.config }} tests + if: matrix.xcode == '15.4' run: make CONFIG=${{ matrix.config }} test-library # library-evolution: diff --git a/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift b/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift index fb50d1ed67ad..171c317cc486 100644 --- a/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift +++ b/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift @@ -5,7 +5,7 @@ import XCTest final class AppFeatureTests: XCTestCase { func testDetailEdit() async throws { - var syncUp = SyncUp.mock + let syncUp = SyncUp.mock @Shared(.syncUps) var syncUps = [syncUp] let store = await TestStore(initialState: AppFeature.State()) { AppFeature() @@ -21,8 +21,9 @@ final class AppFeatureTests: XCTestCase { $0.path[id: 0]?.modify(\.detail) { $0.destination = .edit(SyncUpForm.State(syncUp: syncUp)) } } - syncUp.title = "Blob" - await store.send(\.path[id:0].detail.destination.edit.binding.syncUp, syncUp) { + var newSyncUp = syncUp + newSyncUp.title = "Blob" + await store.send(\.path[id:0].detail.destination.edit.binding.syncUp, newSyncUp) { $0.path[id: 0]?.modify(\.detail) { $0.destination?.modify(\.edit) { $0.syncUp.title = "Blob" } } diff --git a/Package.swift b/Package.swift index 54426b591b0c..676d72279e46 100644 --- a/Package.swift +++ b/Package.swift @@ -87,7 +87,7 @@ let package = Package( ) #if compiler(>=6) - for target in package.targets where target.type != .system { + for target in package.targets where target.type != .system && target.type != .test { target.swiftSettings = target.swiftSettings ?? [] target.swiftSettings?.append(contentsOf: [ .enableExperimentalFeature("StrictConcurrency"), diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 000000000000..899c34fadfb6 --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,97 @@ +// swift-tools-version:6.0 + +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "swift-composable-architecture", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library( + name: "ComposableArchitecture", + targets: ["ComposableArchitecture"] + ) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-collections", from: "1.1.0"), + .package(url: "https://github.com/google/swift-benchmark", from: "0.1.0"), + .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.2"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.4"), + .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.3.5"), + .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.1.0"), + .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.2.0"), + .package(url: "https://github.com/pointfreeco/swift-navigation", from: "2.1.0"), + .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), + .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), + .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"601.0.0-prerelease"), + ], + targets: [ + .target( + name: "ComposableArchitecture", + dependencies: [ + "ComposableArchitectureMacros", + .product(name: "CasePaths", package: "swift-case-paths"), + .product(name: "CombineSchedulers", package: "combine-schedulers"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "Perception", package: "swift-perception"), + .product(name: "SwiftUINavigation", package: "swift-navigation"), + .product(name: "UIKitNavigation", package: "swift-navigation"), + ], + resources: [ + .process("Resources/PrivacyInfo.xcprivacy") + ] + ), + .testTarget( + name: "ComposableArchitectureTests", + dependencies: [ + "ComposableArchitecture", + .product(name: "IssueReportingTestSupport", package: "xctest-dynamic-overlay"), + ] + ), + .macro( + name: "ComposableArchitectureMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + .testTarget( + name: "ComposableArchitectureMacrosTests", + dependencies: [ + "ComposableArchitectureMacros", + .product(name: "MacroTesting", package: "swift-macro-testing"), + ] + ), + .executableTarget( + name: "swift-composable-architecture-benchmark", + dependencies: [ + "ComposableArchitecture", + .product(name: "Benchmark", package: "swift-benchmark"), + ] + ), + ], + swiftLanguageModes: [.v6] +) + +for target in package.targets where target.type == .system || target.type == .test { + target.swiftSettings = target.swiftSettings ?? [] + target.swiftSettings?.append(contentsOf: [ + .swiftLanguageMode(.v5), + .enableExperimentalFeature("StrictConcurrency"), + .enableUpcomingFeature("InferSendableFromCaptures") + ]) +} diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift index 7d081dccb9bb..06a858f52214 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift @@ -730,7 +730,7 @@ public struct _PresentedID: Hashable, Sendable { extension Task { internal static func _cancel( - id: some Hashable, + id: some Hashable & Sendable, navigationID: NavigationIDPath ) { withDependencies { @@ -754,7 +754,7 @@ extension Effect { } } internal static func _cancel( - id: some Hashable = _PresentedID(), + id: some Hashable & Sendable = _PresentedID(), navigationID: NavigationIDPath ) -> Self { withDependencies { diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift index 7c8a42bf0961..710b6b4067ca 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift @@ -7,9 +7,9 @@ /// /// See the article for more information, in particular the /// section. -public protocol PersistenceReaderKey { +public protocol PersistenceReaderKey: Sendable { /// A type that can be loaded or subscribed to in an external system. - associatedtype Value + associatedtype Value: Sendable /// A type representing the hashable identity of a persistence key. associatedtype ID: Hashable = Self diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift index 464b3b0c2bf1..caef3bac7824 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift @@ -154,135 +154,135 @@ extension PersistenceReaderKey { /// A type defining a user defaults persistence strategy. /// /// See ``PersistenceReaderKey/appStorage(_:)-4l5b`` to create values of this type. -public struct AppStorageKey { +public struct AppStorageKey: Sendable { private let lookup: any Lookup private let key: String - private let store: UserDefaults + private let store: UncheckedSendable public var id: AnyHashable { - AppStorageKeyID(key: self.key, store: self.store) + AppStorageKeyID(key: self.key, store: self.store.wrappedValue) } fileprivate init(_ key: String) where Value == Bool { @Dependency(\.defaultAppStorage) var store self.lookup = CastableLookup() self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == Int { @Dependency(\.defaultAppStorage) var store self.lookup = CastableLookup() self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == Double { @Dependency(\.defaultAppStorage) var store self.lookup = CastableLookup() self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == String { @Dependency(\.defaultAppStorage) var store self.lookup = CastableLookup() self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == URL { @Dependency(\.defaultAppStorage) var store self.lookup = URLLookup() self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == Data { @Dependency(\.defaultAppStorage) var store self.lookup = CastableLookup() self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value: RawRepresentable { @Dependency(\.defaultAppStorage) var store self.lookup = RawRepresentableLookup(base: CastableLookup()) self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value: RawRepresentable { @Dependency(\.defaultAppStorage) var store self.lookup = RawRepresentableLookup(base: CastableLookup()) self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == Bool? { @Dependency(\.defaultAppStorage) var store self.lookup = OptionalLookup(base: CastableLookup()) self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == Int? { @Dependency(\.defaultAppStorage) var store self.lookup = OptionalLookup(base: CastableLookup()) self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == Double? { @Dependency(\.defaultAppStorage) var store self.lookup = OptionalLookup(base: CastableLookup()) self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == String? { @Dependency(\.defaultAppStorage) var store self.lookup = OptionalLookup(base: CastableLookup()) self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == URL? { @Dependency(\.defaultAppStorage) var store self.lookup = OptionalLookup(base: URLLookup()) self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init(_ key: String) where Value == Data? { @Dependency(\.defaultAppStorage) var store self.lookup = OptionalLookup(base: CastableLookup()) self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init>(_ key: String) where Value == R? { @Dependency(\.defaultAppStorage) var store self.lookup = OptionalLookup(base: RawRepresentableLookup(base: CastableLookup())) self.key = key - self.store = store + self.store = UncheckedSendable(store) } fileprivate init>(_ key: String) where Value == R? { @Dependency(\.defaultAppStorage) var store self.lookup = OptionalLookup(base: RawRepresentableLookup(base: CastableLookup())) self.key = key - self.store = store + self.store = UncheckedSendable(store) } } extension AppStorageKey: PersistenceKey { public func load(initialValue: Value?) -> Value? { - self.lookup.loadValue(from: self.store, at: self.key, default: initialValue) + self.lookup.loadValue(from: self.store.wrappedValue, at: self.key, default: initialValue) } public func save(_ value: Value) { - self.lookup.saveValue(value, to: self.store, at: self.key) + self.lookup.saveValue(value, to: self.store.wrappedValue, at: self.key) } public func subscribe( @@ -292,7 +292,7 @@ extension AppStorageKey: PersistenceKey { let previousValue = LockIsolated(initialValue) let userDefaultsDidChange = NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, - object: self.store, + object: self.store.wrappedValue, queue: nil ) { _ in let newValue = load(initialValue: initialValue) @@ -362,13 +362,13 @@ private enum SharedAppStorageLocals { @TaskLocal static var isSetting = false } -private protocol Lookup { - associatedtype Value +private protocol Lookup: Sendable { + associatedtype Value: Sendable func loadValue(from store: UserDefaults, at key: String, default defaultValue: Value?) -> Value? func saveValue(_ newValue: Value, to store: UserDefaults, at key: String) } -private struct CastableLookup: Lookup { +private struct CastableLookup: Lookup, Sendable { func loadValue( from store: UserDefaults, at key: String, @@ -421,7 +421,7 @@ private struct URLLookup: Lookup { } } -private struct RawRepresentableLookup: Lookup +private struct RawRepresentableLookup: Lookup, Sendable where Value.RawValue == Base.Value { let base: Base func loadValue( diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift index 6d91d09eaf05..39bf78864f29 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift @@ -15,7 +15,7 @@ extension PersistenceReaderKey { /// - Parameter key: A string key identifying a value to share in memory. /// - Returns: A persistence key. public static func appStorage( - _ keyPath: ReferenceWritableKeyPath + _ keyPath: _ReferenceWritableKeyPath ) -> Self where Self == AppStorageKeyPathKey { AppStorageKeyPathKey(keyPath) } @@ -24,25 +24,25 @@ extension PersistenceReaderKey { /// A type defining a user defaults persistence strategy via key path. /// /// See ``PersistenceReaderKey/appStorage(_:)-5jsie`` to create values of this type. -public struct AppStorageKeyPathKey { - private let keyPath: ReferenceWritableKeyPath - private let store: UserDefaults +public struct AppStorageKeyPathKey: Sendable { + private let keyPath: _ReferenceWritableKeyPath + private let store: UncheckedSendable - public init(_ keyPath: ReferenceWritableKeyPath) { + public init(_ keyPath: _ReferenceWritableKeyPath) { @Dependency(\.defaultAppStorage) var store self.keyPath = keyPath - self.store = store + self.store = UncheckedSendable(store) } } extension AppStorageKeyPathKey: PersistenceKey, Hashable { public func load(initialValue _: Value?) -> Value? { - self.store[keyPath: self.keyPath] + self.store.wrappedValue[keyPath: self.keyPath] } public func save(_ newValue: Value) { SharedAppStorageLocals.$isSetting.withValue(true) { - self.store[keyPath: self.keyPath] = newValue + self.store.wrappedValue[keyPath: self.keyPath] = newValue } } @@ -50,7 +50,7 @@ extension AppStorageKeyPathKey: PersistenceKey, Hashable { initialValue: Value?, didSet: @escaping @Sendable (_ newValue: Value?) -> Void ) -> Shared.Subscription { - let observer = self.store.observe(self.keyPath, options: .new) { _, change in + let observer = self.store.wrappedValue.observe(self.keyPath, options: .new) { _, change in guard !SharedAppStorageLocals.isSetting else { return } diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift index 36395ce088b6..cddf26cd73b2 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift @@ -50,8 +50,6 @@ public final class FileStorageKey: PersistenceKey, Sendable { private let decode: @Sendable (Data) throws -> Value private let encode: @Sendable (Value) throws -> Data fileprivate let state = LockIsolated(State()) - // private let value = LockIsolated(nil) - // private let workItem = LockIsolated(nil) fileprivate struct State { var value: Value? @@ -262,7 +260,7 @@ public struct FileStorage: Hashable, Sendable { let createDirectory: @Sendable (URL, Bool) throws -> Void let fileExists: @Sendable (URL) -> Bool let fileSystemSource: - @Sendable (URL, DispatchSource.FileSystemEvent, @escaping () -> Void) -> AnyCancellable + @Sendable (URL, DispatchSource.FileSystemEvent, @escaping @Sendable () -> Void) -> AnyCancellable let load: @Sendable (URL) throws -> Data @_spi(Internals) public let save: @Sendable (Data, URL) throws -> Void @@ -271,7 +269,7 @@ public struct FileStorage: Hashable, Sendable { /// /// This is the version of the ``Dependencies/DependencyValues/defaultFileStorage`` dependency /// that is used by default when running your app in the simulator or on device. - public static var fileSystem = fileSystem( + public static let fileSystem = fileSystem( queue: DispatchQueue(label: "co.pointfree.ComposableArchitecture.FileStorage") ) @@ -345,9 +343,9 @@ public struct FileStorage: Hashable, Sendable { ) } - fileprivate struct Handler: Hashable { + fileprivate struct Handler: Hashable, Sendable { let id = UUID() - let operation: () -> Void + let operation: @Sendable () -> Void static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift index 58e263faa075..c07ec77a02d0 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift @@ -23,7 +23,7 @@ extension PersistenceReaderKey { /// A type defining an in-memory persistence strategy /// /// See ``PersistenceReaderKey/inMemory(_:)`` to create values of this type. -public struct InMemoryKey: PersistenceKey, Sendable { +public struct InMemoryKey: PersistenceKey, Sendable { private let key: String private let store: InMemoryStorage fileprivate init(_ key: String) { diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/PersistenceKeyDefault.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/PersistenceKeyDefault.swift index db7efb003c85..64d45aa07679 100644 --- a/Sources/ComposableArchitecture/SharedState/PersistenceKey/PersistenceKeyDefault.swift +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/PersistenceKeyDefault.swift @@ -24,9 +24,9 @@ /// ``` public struct PersistenceKeyDefault: PersistenceReaderKey { let base: Base - let defaultValue: () -> Base.Value + let defaultValue: @Sendable () -> Base.Value - public init(_ key: Base, _ value: @autoclosure @escaping () -> Base.Value) { + public init(_ key: Base, _ value: @autoclosure @escaping @Sendable () -> Base.Value) { self.base = key self.defaultValue = value } diff --git a/Sources/ComposableArchitecture/SharedState/Reference.swift b/Sources/ComposableArchitecture/SharedState/Reference.swift index c936476ad4f7..c5db9108236e 100644 --- a/Sources/ComposableArchitecture/SharedState/Reference.swift +++ b/Sources/ComposableArchitecture/SharedState/Reference.swift @@ -2,8 +2,8 @@ import Combine #endif -protocol Reference: AnyObject, CustomStringConvertible { - associatedtype Value +protocol Reference: AnyObject, CustomStringConvertible, Sendable { + associatedtype Value: Sendable var value: Value { get set } func access() diff --git a/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift b/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift index 45d89afd48aa..019574bbf73b 100644 --- a/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift +++ b/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift @@ -17,7 +17,7 @@ extension Shared { /// - persistenceKey: A persistence key associated with the shared reference. It is responsible /// for loading and saving the shared reference's value from some external source. public init( - wrappedValue value: @autoclosure @escaping () -> Value, + wrappedValue value: @autoclosure @Sendable () -> Value, _ persistenceKey: some PersistenceKey, fileID: StaticString = #fileID, line: UInt = #line @@ -93,7 +93,7 @@ extension Shared { } private init( - throwingValue value: @autoclosure @escaping () throws -> Value, + throwingValue value: @autoclosure @Sendable () throws -> Value, _ persistenceKey: some PersistenceKey, fileID: StaticString = #fileID, line: UInt = #line @@ -154,7 +154,7 @@ extension Shared { /// - persistenceKey: A persistence key associated with the shared reference. It is responsible /// for loading and saving the shared reference's value from some external source. public init>( - wrappedValue value: @autoclosure @escaping () -> Value, + wrappedValue value: @autoclosure @Sendable () -> Value, _ persistenceKey: PersistenceKeyDefault, fileID: StaticString = #fileID, line: UInt = #line @@ -177,7 +177,7 @@ extension SharedReader { /// - persistenceKey: A persistence key associated with the shared reference. It is responsible /// for loading the shared reference's value from some external source. public init( - wrappedValue value: @autoclosure @escaping () -> Value, + wrappedValue value: @autoclosure @Sendable () -> Value, _ persistenceKey: some PersistenceReaderKey, fileID: StaticString = #fileID, line: UInt = #line @@ -252,7 +252,7 @@ extension SharedReader { } private init( - throwingValue value: @autoclosure @escaping () throws -> Value, + throwingValue value: @autoclosure @Sendable () throws -> Value, _ persistenceKey: some PersistenceReaderKey, fileID: StaticString = #fileID, line: UInt = #line @@ -312,7 +312,7 @@ extension SharedReader { /// - persistenceKey: A persistence key associated with the shared reference. It is responsible /// for loading the shared reference's value from some external source. public init>( - wrappedValue value: @autoclosure @escaping () -> Value, + wrappedValue value: @autoclosure @Sendable () -> Value, _ persistenceKey: PersistenceKeyDefault, fileID: StaticString = #fileID, line: UInt = #line @@ -328,15 +328,15 @@ extension SharedReader { private struct LoadError: Error {} -final class ValueReference>: Reference, @unchecked - Sendable +final class ValueReference>: Reference, + @unchecked Sendable { private let lock = NSRecursiveLock() private let persistenceKey: Persistence? #if canImport(Combine) private let subject: CurrentValueRelay #endif - private var subscription: Shared.Subscription? + private var subscription: Shared.Subscription! private var _value: Value { willSet { self.subject.send(newValue) diff --git a/Sources/ComposableArchitecture/SharedState/Shared.swift b/Sources/ComposableArchitecture/SharedState/Shared.swift index d47c6031739d..589ff67d7e73 100644 --- a/Sources/ComposableArchitecture/SharedState/Shared.swift +++ b/Sources/ComposableArchitecture/SharedState/Shared.swift @@ -12,11 +12,11 @@ import IssueReporting /// wrapper. @dynamicMemberLookup @propertyWrapper -public struct Shared { +public struct Shared: Sendable { private let reference: any Reference - private let keyPath: AnyKeyPath + private let keyPath: _AnyKeyPath - init(reference: any Reference, keyPath: AnyKeyPath) { + init(reference: any Reference, keyPath: _AnyKeyPath) { self.reference = reference self.keyPath = keyPath } @@ -62,7 +62,13 @@ public struct Shared { else { return nil } self.init( reference: base.reference, - keyPath: base.keyPath.appending(path: \Value?.[default:DefaultSubscript(initialValue)])! + // NB: Can get rid of bitcast when this is fixed: + // https://github.com/swiftlang/swift/issues/75531 + keyPath: unsafeBitCast( + (base.keyPath as AnyKeyPath) + .appending(path: \Value?.[default:DefaultSubscript(initialValue)])!, + to: _AnyKeyPath.self + ) ) } @@ -170,7 +176,15 @@ public struct Shared { public subscript( dynamicMember keyPath: WritableKeyPath ) -> Shared { - Shared(reference: self.reference, keyPath: self.keyPath.appending(path: keyPath)!) + Shared( + reference: self.reference, + // NB: Can get rid of bitcast when this is fixed: + // https://github.com/swiftlang/swift/issues/75531 + keyPath: unsafeBitCast( + (self.keyPath as AnyKeyPath).appending(path: keyPath)!, + to: _AnyKeyPath.self + ) + ) } @_disfavoredOverload @@ -275,7 +289,7 @@ public struct Shared { private var snapshot: Value? { get { - func open(_ reference: some Reference) -> Value? { + func open(_ reference: some Reference) -> Value? { @Dependency(\.sharedChangeTracker) var changeTracker return changeTracker?[reference]?.snapshot[ keyPath: unsafeDowncast(self.keyPath, to: WritableKeyPath.self) @@ -284,7 +298,7 @@ public struct Shared { return open(self.reference) } nonmutating set { - func open(_ reference: some Reference) { + func open(_ reference: some Reference) { @Dependency(\.sharedChangeTracker) var changeTracker guard let newValue else { changeTracker?[reference] = nil @@ -302,8 +316,6 @@ public struct Shared { } } -extension Shared: @unchecked Sendable where Value: Sendable {} - extension Shared: Equatable where Value: Equatable { public static func == (lhs: Shared, rhs: Shared) -> Bool { @Dependency(\.sharedChangeTracker) var changeTracker @@ -344,7 +356,10 @@ extension Shared: _CustomDiffObject { } extension Shared -where Value: _MutableIdentifiedCollection { +where + Value: _MutableIdentifiedCollection, + Value.Element: Sendable +{ /// Allows a `ForEach` view to transform a shared collection into shared elements. /// /// ```swift @@ -408,11 +423,10 @@ extension Shared: MutableCollection where Value: MutableCollection & RandomAccessCollection, Value.Index: Hashable { public subscript(position: Value.Index) -> Shared { get { - assertionFailure("Conformance of 'Shared' to 'MutableCollection' is unavailable.") - return self[position, default: DefaultSubscript(self.wrappedValue[position])] + fatalError("Conformance of 'Shared' to 'MutableCollection' is unavailable.") } set { - self._wrappedValue[position] = newValue.wrappedValue + fatalError("Conformance of 'Shared' to 'MutableCollection' is unavailable.") } } } @@ -447,7 +461,12 @@ extension Shared { ) -> SharedReader { SharedReader( reference: self.reference, - keyPath: self.keyPath.appending(path: keyPath)! + // NB: Can get rid of bitcast when this is fixed: + // https://github.com/swiftlang/swift/issues/75531 + keyPath: unsafeBitCast( + (self.keyPath as AnyKeyPath).appending(path: keyPath)!, + to: _AnyKeyPath.self + ) ) } diff --git a/Sources/ComposableArchitecture/SharedState/SharedChangeTracking.swift b/Sources/ComposableArchitecture/SharedState/SharedChangeTracking.swift index 34484e284c7a..1eda500d8366 100644 --- a/Sources/ComposableArchitecture/SharedState/SharedChangeTracking.swift +++ b/Sources/ComposableArchitecture/SharedState/SharedChangeTracking.swift @@ -46,7 +46,7 @@ extension Change { } } -struct AnyChange: Change { +struct AnyChange: Change, Sendable { let reference: any Reference var snapshot: Value @@ -58,7 +58,7 @@ struct AnyChange: Change { @_spi(Internals) public final class SharedChangeTracker: Sendable { - let changes: LockIsolated<[ObjectIdentifier: Any]> = LockIsolated([:]) + let changes: LockIsolated<[ObjectIdentifier: any Sendable]> = LockIsolated([:]) var hasChanges: Bool { !self.changes.isEmpty } @_spi(Internals) public init() {} func resetChanges() { self.changes.withValue { $0.removeAll() } } @@ -70,7 +70,7 @@ public final class SharedChangeTracker: Sendable { } self.resetChanges() } - func track(_ reference: some Reference) { + func track(_ reference: some Reference) { if !self.changes.keys.contains(ObjectIdentifier(reference)) { self.changes.withValue { $0[ObjectIdentifier(reference)] = AnyChange(reference) } } diff --git a/Sources/ComposableArchitecture/SharedState/SharedReader.swift b/Sources/ComposableArchitecture/SharedState/SharedReader.swift index facafae2e06f..99fa4297ddf5 100644 --- a/Sources/ComposableArchitecture/SharedState/SharedReader.swift +++ b/Sources/ComposableArchitecture/SharedState/SharedReader.swift @@ -8,7 +8,7 @@ /// wrapper, in particular . @dynamicMemberLookup @propertyWrapper -public struct SharedReader { +public struct SharedReader { fileprivate let reference: any Reference fileprivate let keyPath: AnyKeyPath @@ -47,7 +47,7 @@ public struct SharedReader { else { return nil } self.init( reference: base.reference, - keyPath: base.keyPath.appending(path: \Value?.[default:DefaultSubscript(initialValue)])! + keyPath: base.keyPath.appending(path: \Value?.[default: DefaultSubscript(initialValue)])! ) } @@ -183,7 +183,11 @@ extension SharedReader: CustomDumpRepresentable { } extension SharedReader -where Value: RandomAccessCollection & MutableCollection, Value.Index: Hashable & Sendable { +where + Value: RandomAccessCollection & MutableCollection, + Value.Index: Hashable & Sendable, + Value.Element: Sendable +{ /// Derives a collection of read-only shared elements from a read-only shared collection of /// elements. /// diff --git a/Tests/ComposableArchitectureTests/EffectRunTests.swift b/Tests/ComposableArchitectureTests/EffectRunTests.swift index 94e4a8062414..f9748747db2b 100644 --- a/Tests/ComposableArchitectureTests/EffectRunTests.swift +++ b/Tests/ComposableArchitectureTests/EffectRunTests.swift @@ -147,7 +147,7 @@ final class EffectRunTests: BaseTCATestCase { let queue = DispatchQueue.test - let store = await Store(initialState: 0) { + let store = Store(initialState: 0) { Reduce { _, action in switch action { case .tap: diff --git a/Tests/ComposableArchitectureTests/SharedTests.swift b/Tests/ComposableArchitectureTests/SharedTests.swift index 218fd9e2cf36..8880c1ab80ae 100644 --- a/Tests/ComposableArchitectureTests/SharedTests.swift +++ b/Tests/ComposableArchitectureTests/SharedTests.swift @@ -1004,7 +1004,7 @@ final class SharedTests: XCTestCase { } func testReEntrantSharedSubscriptionDependencyResolution() async throws { - for _ in 1...100 { + for _ in 1...10 { try await withDependencies { $0 = DependencyValues() } operation: { @@ -1034,7 +1034,7 @@ final class SharedTests: XCTestCase { } } - try await Task.sleep(nanoseconds: 10_000_000) + try await Task.sleep(nanoseconds: 1_000_000_000) XCTAssertEqual(count, 42) } }