From 9b5a510174ba956839035e8254d4be97f0c9ada1 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 9 Jan 2023 11:08:23 -0500 Subject: [PATCH 01/38] Depend on swift-dependencies (#1808) Co-authored-by: Brandon Williams --- .github/workflows/ci.yml | 1 + .../xcshareddata/swiftpm/Package.resolved | 213 +++++---- .../xcschemes/ComposableArchitecture.xcscheme | 10 - .../xcschemes/Dependencies.xcscheme | 77 --- .../SpeechClient/Client.swift | 1 - Examples/TicTacToe/tic-tac-toe/Package.swift | 5 +- .../AudioRecorderClient.swift | 1 - .../LiveAudioRecorderClient.swift | 2 +- Package.resolved | 41 +- Package.swift | 25 +- README.md | 228 ++++++--- .../Articles/DependencyManagement.md | 441 +----------------- .../ComposableArchitecture.md | 1 - .../Extensions/TestStore.md | 2 +- Sources/ComposableArchitecture/Effect.swift | 159 ++++--- .../Effects/ConcurrencySupport.swift | 422 ----------------- .../Effects/Publisher.swift | 63 +-- .../Internal/SerialExecutor.swift | 12 + .../DependencyKeyWritingReducer.swift | 2 +- .../ComposableArchitecture/TestStore.swift | 25 +- .../Dependencies/Dependencies/Calendar.swift | 42 -- .../Dependencies/Dependencies/Clocks.swift | 24 - .../Dependencies/Dependencies/Context.swift | 25 - Sources/Dependencies/Dependencies/Date.swift | 84 ---- .../Dependencies/Dependencies/Locale.swift | 54 --- .../Dependencies/Dependencies/MainQueue.swift | 97 ---- .../Dependencies/MainRunLoop.swift | 96 ---- .../Dependencies/Dependencies/OpenURL.swift | 61 --- .../Dependencies/RandomNumberGenerator.swift | 101 ---- .../Dependencies/Dependencies/TimeZone.swift | 33 -- .../Dependencies/URLSession.swift | 108 ----- Sources/Dependencies/Dependencies/UUID.swift | 139 ------ Sources/Dependencies/Dependency.swift | 94 ---- Sources/Dependencies/DependencyContext.swift | 32 -- Sources/Dependencies/DependencyKey.swift | 239 ---------- Sources/Dependencies/DependencyValues.swift | 394 ---------------- .../Documentation.docc/Dependencies.md | 12 - .../Extensions/Dependency.md | 11 - .../Extensions/DependencyKey.md | 14 - .../Extensions/DependencyValues.md | 27 -- .../Extensions/DependencyValuesContext.md | 7 - .../Extensions/DependencyValuesDate.md | 7 - .../Extensions/DependencyValuesOpenURL.md | 7 - .../Extensions/DependencyValuesUUID.md | 7 - ...pendencyValuesWithRandomNumberGenerator.md | 7 - .../Extensions/DependencyValuesWithValue.md | 7 - .../Extensions/DependencyValuesWithValues.md | 7 - .../Extensions/TestDependencyKey.md | 9 - .../Internal/OpenExistential.swift | 31 -- .../Internal/RuntimeWarnings.swift | 71 --- Sources/Dependencies/Internal/TypeName.swift | 15 - Sources/_CAsyncSupport/_CAsyncSupport.h | 248 ++++++++++ Sources/_CAsyncSupport/module.modulemap | 4 + .../Dependencies.swift | 30 +- .../EffectTests.swift | 70 +-- .../DependencyKeyTests.swift | 158 ------- .../DependencyValuesTests.swift | 275 ----------- 57 files changed, 775 insertions(+), 3603 deletions(-) delete mode 100644 ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/Dependencies.xcscheme delete mode 100644 Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift create mode 100644 Sources/ComposableArchitecture/Internal/SerialExecutor.swift delete mode 100644 Sources/Dependencies/Dependencies/Calendar.swift delete mode 100644 Sources/Dependencies/Dependencies/Clocks.swift delete mode 100644 Sources/Dependencies/Dependencies/Context.swift delete mode 100644 Sources/Dependencies/Dependencies/Date.swift delete mode 100644 Sources/Dependencies/Dependencies/Locale.swift delete mode 100644 Sources/Dependencies/Dependencies/MainQueue.swift delete mode 100644 Sources/Dependencies/Dependencies/MainRunLoop.swift delete mode 100644 Sources/Dependencies/Dependencies/OpenURL.swift delete mode 100644 Sources/Dependencies/Dependencies/RandomNumberGenerator.swift delete mode 100644 Sources/Dependencies/Dependencies/TimeZone.swift delete mode 100644 Sources/Dependencies/Dependencies/URLSession.swift delete mode 100644 Sources/Dependencies/Dependencies/UUID.swift delete mode 100644 Sources/Dependencies/Dependency.swift delete mode 100644 Sources/Dependencies/DependencyContext.swift delete mode 100644 Sources/Dependencies/DependencyKey.swift delete mode 100644 Sources/Dependencies/DependencyValues.swift delete mode 100644 Sources/Dependencies/Documentation.docc/Dependencies.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/Dependency.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/DependencyKey.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/DependencyValues.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesContext.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesDate.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesOpenURL.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesUUID.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithRandomNumberGenerator.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithValue.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithValues.md delete mode 100644 Sources/Dependencies/Documentation.docc/Extensions/TestDependencyKey.md delete mode 100644 Sources/Dependencies/Internal/OpenExistential.swift delete mode 100644 Sources/Dependencies/Internal/RuntimeWarnings.swift delete mode 100644 Sources/Dependencies/Internal/TypeName.swift create mode 100644 Sources/_CAsyncSupport/_CAsyncSupport.h create mode 100644 Sources/_CAsyncSupport/module.modulemap delete mode 100644 Tests/DependenciesTests/DependencyKeyTests.swift delete mode 100644 Tests/DependenciesTests/DependencyValuesTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 378a1702c24e..97ca4fe450b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - swift-dependencies pull_request: branches: - '*' diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved index e6d6ed0296a2..55483553b50e 100644 --- a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,106 +1,113 @@ { - "object": { - "pins": [ - { - "package": "combine-schedulers", - "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", - "state": { - "branch": null, - "revision": "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", - "version": "0.9.1" - } - }, - { - "package": "swift-argument-parser", - "repositoryURL": "https://github.com/apple/swift-argument-parser", - "state": { - "branch": null, - "revision": "9f39744e025c7d377987f30b03770805dcb0bcd1", - "version": "1.1.4" - } - }, - { - "package": "Benchmark", - "repositoryURL": "https://github.com/google/swift-benchmark", - "state": { - "branch": null, - "revision": "8163295f6fe82356b0bcf8e1ab991645de17d096", - "version": "0.1.2" - } - }, - { - "package": "swift-case-paths", - "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", - "state": { - "branch": null, - "revision": "bb436421f57269fbcfe7360735985321585a86e5", - "version": "0.10.1" - } - }, - { - "package": "swift-clocks", - "repositoryURL": "https://github.com/pointfreeco/swift-clocks", - "state": { - "branch": null, - "revision": "692ec4f5429a667bdd968c7260dfa2b23adfeffc", - "version": "0.1.4" - } - }, - { - "package": "swift-collections", - "repositoryURL": "https://github.com/apple/swift-collections", - "state": { - "branch": null, - "revision": "f504716c27d2e5d4144fa4794b12129301d17729", - "version": "1.0.3" - } - }, - { - "package": "swift-custom-dump", - "repositoryURL": "https://github.com/pointfreeco/swift-custom-dump", - "state": { - "branch": null, - "revision": "819d9d370cd721c9d87671e29d947279292e4541", - "version": "0.6.0" - } - }, - { - "package": "SwiftDocCPlugin", - "repositoryURL": "https://github.com/apple/swift-docc-plugin", - "state": { - "branch": null, - "revision": "3303b164430d9a7055ba484c8ead67a52f7b74f6", - "version": "1.0.0" - } - }, - { - "package": "swift-identified-collections", - "repositoryURL": "https://github.com/pointfreeco/swift-identified-collections", - "state": { - "branch": null, - "revision": "bfb0d43e75a15b6dfac770bf33479e8393884a36", - "version": "0.4.1" - } - }, - { - "package": "swiftui-navigation", - "repositoryURL": "https://github.com/pointfreeco/swiftui-navigation", - "state": { - "branch": null, - "revision": "46acf5ecc1cabdb28d7fe03289f6c8b13a023f52", - "version": "0.4.5" - } - }, - { - "package": "xctest-dynamic-overlay", - "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state": { - "branch": null, - "revision": "16e6409ee82e1b81390bdffbf217b9c08ab32784", - "version": "0.5.0" - } + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", + "version" : "0.9.1" } - ] - }, - "version": 1 + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "9f39744e025c7d377987f30b03770805dcb0bcd1", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-benchmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/swift-benchmark", + "state" : { + "revision" : "8163295f6fe82356b0bcf8e1ab991645de17d096", + "version" : "0.1.2" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "bb436421f57269fbcfe7360735985321585a86e5", + "version" : "0.10.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "20b25ca0dd88ebfb9111ec937814ddc5a8880172", + "version" : "0.2.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "f504716c27d2e5d4144fa4794b12129301d17729", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "819d9d370cd721c9d87671e29d947279292e4541", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "e49dfe4d9e4c5c06f3334361360b801aef41631c", + "version" : "0.1.1" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "bfb0d43e75a15b6dfac770bf33479e8393884a36", + "version" : "0.4.1" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation", + "state" : { + "revision" : "46acf5ecc1cabdb28d7fe03289f6c8b13a023f52", + "version" : "0.4.5" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "a9daebf0bf65981fd159c885d504481a65a75f02", + "version" : "0.8.0" + } + } + ], + "version" : 2 } diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme b/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme index ed07195d3bd1..b5e120b94df2 100644 --- a/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme +++ b/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme @@ -39,16 +39,6 @@ ReferencedContainer = "container:"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift index e0fcadf499b4..8376d99d68ea 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift @@ -1,4 +1,3 @@ -import ComposableArchitecture import Dependencies import Speech import XCTestDynamicOverlay diff --git a/Examples/TicTacToe/tic-tac-toe/Package.swift b/Examples/TicTacToe/tic-tac-toe/Package.swift index 9b596bdf37ed..188d3a8b272c 100644 --- a/Examples/TicTacToe/tic-tac-toe/Package.swift +++ b/Examples/TicTacToe/tic-tac-toe/Package.swift @@ -27,7 +27,8 @@ let package = Package( .library(name: "TwoFactorUIKit", targets: ["TwoFactorUIKit"]), ], dependencies: [ - .package(name: "swift-composable-architecture", path: "../../..") + .package(name: "swift-composable-architecture", path: "../../.."), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.1.0"), ], targets: [ .target( @@ -63,7 +64,7 @@ let package = Package( .target( name: "AuthenticationClient", dependencies: [ - .product(name: "Dependencies", package: "swift-composable-architecture") + .product(name: "Dependencies", package: "swift-dependencies") ] ), .target( diff --git a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift index 2f69ea76fca6..1f818f70bc81 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift @@ -1,4 +1,3 @@ -import ComposableArchitecture // Required for `ActorIsolated` import Dependencies import Foundation import XCTestDynamicOverlay diff --git a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift index db55e4454c65..5815f0619a21 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift @@ -1,5 +1,5 @@ import AVFoundation -import ComposableArchitecture // TODO: Should `UncheckedSendable` live in `Dependencies`? +import Dependencies extension AudioRecorderClient: DependencyKey { static var liveValue: Self { diff --git a/Package.resolved b/Package.resolved index 8fe484e034b4..1a451627d11f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "aa3e575929f2bcc5bad012bd2575eae716cbcdf7", - "version" : "0.8.0" + "revision" : "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", + "version" : "0.9.1" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "9f39744e025c7d377987f30b03770805dcb0bcd1", - "version" : "1.1.4" + "revision" : "fddd1c00396eed152c45a46bea9f47b98e59301d", + "version" : "1.2.0" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "15bba50ebf3a2065388c8d12210debe4f6ada202", - "version" : "0.10.0" + "revision" : "c3a42e8d1a76ff557cf565ed6d8b0aee0e6e75af", + "version" : "0.11.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "692ec4f5429a667bdd968c7260dfa2b23adfeffc", - "version" : "0.1.4" + "revision" : "20b25ca0dd88ebfb9111ec937814ddc5a8880172", + "version" : "0.2.0" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "f504716c27d2e5d4144fa4794b12129301d17729", - "version" : "1.0.3" + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" } }, { @@ -59,8 +59,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "819d9d370cd721c9d87671e29d947279292e4541", - "version" : "0.6.0" + "revision" : "ead7d30cc224c3642c150b546f4f1080d1c411a8", + "version" : "0.6.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "e49dfe4d9e4c5c06f3334361360b801aef41631c", + "version" : "0.1.1" } }, { @@ -77,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "bfb0d43e75a15b6dfac770bf33479e8393884a36", - "version" : "0.4.1" + "revision" : "fd34c544ad27f3ba6b19142b348005bfa85b6005", + "version" : "0.6.0" } }, { @@ -95,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "16e6409ee82e1b81390bdffbf217b9c08ab32784", - "version" : "0.5.0" + "revision" : "a9daebf0bf65981fd159c885d504481a65a75f02", + "version" : "0.8.0" } } ], diff --git a/Package.swift b/Package.swift index 439d6a5a1aac..527d62a4286f 100644 --- a/Package.swift +++ b/Package.swift @@ -15,18 +15,14 @@ let package = Package( name: "ComposableArchitecture", targets: ["ComposableArchitecture"] ), - .library( - name: "Dependencies", - targets: ["Dependencies"] - ), ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/google/swift-benchmark", from: "0.1.0"), .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.8.0"), .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.10.0"), - .package(url: "https://github.com/pointfreeco/swift-clocks", from: "0.1.4"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.6.0"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.1.1"), .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "0.4.1"), .package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "0.4.5"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.5.0"), @@ -35,10 +31,11 @@ let package = Package( .target( name: "ComposableArchitecture", dependencies: [ - "Dependencies", + "_CAsyncSupport", .product(name: "CasePaths", package: "swift-case-paths"), .product(name: "CombineSchedulers", package: "combine-schedulers"), .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "IdentifiedCollections", package: "swift-identified-collections"), .product(name: "_SwiftUINavigationState", package: "swiftui-navigation"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), @@ -50,21 +47,6 @@ let package = Package( "ComposableArchitecture" ] ), - .target( - name: "Dependencies", - dependencies: [ - .product(name: "Clocks", package: "swift-clocks"), - .product(name: "CombineSchedulers", package: "combine-schedulers"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - ] - ), - .testTarget( - name: "DependenciesTests", - dependencies: [ - "ComposableArchitecture", - "Dependencies", - ] - ), .executableTarget( name: "swift-composable-architecture-benchmark", dependencies: [ @@ -72,6 +54,7 @@ let package = Package( .product(name: "Benchmark", package: "swift-benchmark"), ] ), + .systemLibrary(name: "_CAsyncSupport"), ] ) diff --git a/README.md b/README.md index 3a7a964b35d2..4742ecb51126 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-composable-architecture%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/swift-composable-architecture) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-composable-architecture%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/swift-composable-architecture) -The Composable Architecture (TCA, for short) is a library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. It can be used in SwiftUI, UIKit, and more, and on any Apple platform (iOS, macOS, tvOS, and watchOS). +The Composable Architecture (TCA, for short) is a library for building applications in a consistent +and understandable way, with composition, testing, and ergonomics in mind. It can be used in +SwiftUI, UIKit, and more, and on any Apple platform (iOS, macOS, tvOS, and watchOS). * [What is the Composable Architecture?](#what-is-the-composable-architecture) * [Learn more](#learn-more) @@ -17,28 +19,40 @@ The Composable Architecture (TCA, for short) is a library for building applicati ## What is the Composable Architecture? -This library provides a few core tools that can be used to build applications of varying purpose and complexity. It provides compelling stories that you can follow to solve many problems you encounter day-to-day when building applications, such as: +This library provides a few core tools that can be used to build applications of varying purpose and +complexity. It provides compelling stories that you can follow to solve many problems you encounter +day-to-day when building applications, such as: * **State management** -
How to manage the state of your application using simple value types, and share state across many screens so that mutations in one screen can be immediately observed in another screen. +
How to manage the state of your application using simple value types, and share state across + many screens so that mutations in one screen can be immediately observed in another screen. * **Composition** -
How to break down large features into smaller components that can be extracted to their own, isolated modules and be easily glued back together to form the feature. +
How to break down large features into smaller components that can be extracted to their own, + isolated modules and be easily glued back together to form the feature. * **Side effects** -
How to let certain parts of the application talk to the outside world in the most testable and understandable way possible. +
How to let certain parts of the application talk to the outside world in the most testable + and understandable way possible. * **Testing** -
How to not only test a feature built in the architecture, but also write integration tests for features that have been composed of many parts, and write end-to-end tests to understand how side effects influence your application. This allows you to make strong guarantees that your business logic is running in the way you expect. +
How to not only test a feature built in the architecture, but also write integration tests + for features that have been composed of many parts, and write end-to-end tests to understand how + side effects influence your application. This allows you to make strong guarantees that your + business logic is running in the way you expect. * **Ergonomics** -
How to accomplish all of the above in a simple API with as few concepts and moving parts as possible. +
How to accomplish all of the above in a simple API with as few concepts and moving parts as + possible. ## Learn More -The Composable Architecture was designed over the course of many episodes on [Point-Free][pointfreeco], a video series exploring functional programming and the Swift language, hosted by [Brandon Williams][mbrandonw] and [Stephen Celis][stephencelis]. +The Composable Architecture was designed over the course of many episodes on +[Point-Free][pointfreeco], a video series exploring functional programming and the Swift language, +hosted by [Brandon Williams][mbrandonw] and [Stephen Celis][stephencelis]. -You can watch all of the episodes [here][tca-episode-collection], as well as a dedicated, [multipart tour][tca-tour] of the architecture from scratch. +You can watch all of the episodes [here][tca-episode-collection], as well as a dedicated, [multipart +tour][tca-tour] of the architecture from scratch. video poster image @@ -48,7 +62,8 @@ You can watch all of the episodes [here][tca-episode-collection], as well as a d [![Screen shots of example applications](https://d3rccdn33rt8ze.cloudfront.net/composable-architecture/demos.png)](./Examples) -This repo comes with _lots_ of examples to demonstrate how to solve common and complex problems with the Composable Architecture. Check out [this](./Examples) directory to see them all, including: +This repo comes with _lots_ of examples to demonstrate how to solve common and complex problems with +the Composable Architecture. Check out [this](./Examples) directory to see them all, including: * [Case Studies](./Examples/CaseStudies) * Getting started @@ -64,22 +79,35 @@ This repo comes with _lots_ of examples to demonstrate how to solve common and c * [Todos](./Examples/Todos) * [Voice memos](./Examples/VoiceMemos) -Looking for something more substantial? Check out the source code for [isowords][gh-isowords], an iOS word search game built in SwiftUI and the Composable Architecture. +Looking for something more substantial? Check out the source code for [isowords][gh-isowords], an +iOS word search game built in SwiftUI and the Composable Architecture. ## Basic Usage -To build a feature using the Composable Architecture you define some types and values that model your domain: +To build a feature using the Composable Architecture you define some types and values that model +your domain: -* **State**: A type that describes the data your feature needs to perform its logic and render its UI. -* **Action**: A type that represents all of the actions that can happen in your feature, such as user actions, notifications, event sources and more. -* **Reducer**: A function that describes how to evolve the current state of the app to the next state given an action. The reducer is also responsible for returning any effects that should be run, such as API requests, which can be done by returning an `Effect` value. -* **Store**: The runtime that actually drives your feature. You send all user actions to the store so that the store can run the reducer and effects, and you can observe state changes in the store so that you can update UI. +* **State**: A type that describes the data your feature needs to perform its logic and render its +UI. +* **Action**: A type that represents all of the actions that can happen in your feature, such as +user actions, notifications, event sources and more. +* **Reducer**: A function that describes how to evolve the current state of the app to the next +state given an action. The reducer is also responsible for returning any effects that should be +run, such as API requests, which can be done by returning an `Effect` value. +* **Store**: The runtime that actually drives your feature. You send all user actions to the store +so that the store can run the reducer and effects, and you can observe state changes in the store +so that you can update UI. -The benefits of doing this are that you will instantly unlock testability of your feature, and you will be able to break large, complex features into smaller domains that can be glued together. +The benefits of doing this are that you will instantly unlock testability of your feature, and you +will be able to break large, complex features into smaller domains that can be glued together. -As a basic example, consider a UI that shows a number along with "+" and "−" buttons that increment and decrement the number. To make things interesting, suppose there is also a button that when tapped makes an API request to fetch a random fact about that number and then displays the fact in an alert. +As a basic example, consider a UI that shows a number along with "+" and "−" buttons that increment +and decrement the number. To make things interesting, suppose there is also a button that when +tapped makes an API request to fetch a random fact about that number and then displays the fact in +an alert. -To implement this feature we create a new type that will house the domain and behavior of the feature by conforming to `ReducerProtocol`: +To implement this feature we create a new type that will house the domain and behavior of the +feature by conforming to `ReducerProtocol`: ```swift import ComposableArchitecture @@ -88,7 +116,9 @@ struct Feature: ReducerProtocol { } ``` -In here we need to define a type for the feature's state, which consists of an integer for the current count, as well as an optional string that represents the title of the alert we want to show (optional because `nil` represents not showing an alert): +In here we need to define a type for the feature's state, which consists of an integer for the +current count, as well as an optional string that represents the title of the alert we want to show +(optional because `nil` represents not showing an alert): ```swift struct Feature: ReducerProtocol { @@ -99,7 +129,10 @@ struct Feature: ReducerProtocol { } ``` -We also need to define a type for the feature's actions. There are the obvious actions, such as tapping the decrement button, increment button, or fact button. But there are also some slightly non-obvious ones, such as the action of the user dismissing the alert, and the action that occurs when we receive a response from the fact API request: +We also need to define a type for the feature's actions. There are the obvious actions, such as +tapping the decrement button, increment button, or fact button. But there are also some slightly +non-obvious ones, such as the action of the user dismissing the alert, and the action that occurs +when we receive a response from the fact API request: ```swift struct Feature: ReducerProtocol { @@ -114,7 +147,10 @@ struct Feature: ReducerProtocol { } ``` -And then we implement the `reduce` method which is responsible for handling the actual logic and behavior for the feature. It describes how to change the current state to the next state, and describes what effects need to be executed. Some actions don't need to execute effects, and they can return `.none` to represent that: +And then we implement the `reduce` method which is responsible for handling the actual logic and +behavior for the feature. It describes how to change the current state to the next state, and +describes what effects need to be executed. Some actions don't need to execute effects, and they +can return `.none` to represent that: ```swift struct Feature: ReducerProtocol { @@ -160,7 +196,10 @@ struct Feature: ReducerProtocol { } ``` -And then finally we define the view that displays the feature. It holds onto a `StoreOf` so that it can observe all changes to the state and re-render, and we can send all user actions to the store so that state changes. We must also introduce a struct wrapper around the fact alert to make it `Identifiable`, which the `.alert` view modifier requires: +And then finally we define the view that displays the feature. It holds onto a `StoreOf` +so that it can observe all changes to the state and re-render, and we can send all user actions to +the store so that state changes. We must also introduce a struct wrapper around the fact alert to +make it `Identifiable`, which the `.alert` view modifier requires: ```swift struct FeatureView: View { @@ -194,7 +233,9 @@ struct FactAlert: Identifiable { } ``` -It is also straightforward to have a UIKit controller driven off of this store. You subscribe to the store in `viewDidLoad` in order to update the UI and show alerts. The code is a bit longer than the SwiftUI version, so we have collapsed it here: +It is also straightforward to have a UIKit controller driven off of this store. You subscribe to the +store in `viewDidLoad` in order to update the UI and show alerts. The code is a bit longer than the +SwiftUI version, so we have collapsed it here:
Click to expand! @@ -258,7 +299,9 @@ It is also straightforward to have a UIKit controller driven off of this store. ```
-Once we are ready to display this view, for example in the app's entry point, we can construct a store. This can be done by specifying the initial state to start the application in, as well as the reducer that will power the application: +Once we are ready to display this view, for example in the app's entry point, we can construct a +store. This can be done by specifying the initial state to start the application in, as well as +the reducer that will power the application: ```swift import ComposableArchitecture @@ -278,13 +321,19 @@ struct MyApp: App { } ``` -And that is enough to get something on the screen to play around with. It's definitely a few more steps than if you were to do this in a vanilla SwiftUI way, but there are a few benefits. It gives us a consistent manner to apply state mutations, instead of scattering logic in some observable objects and in various action closures of UI components. It also gives us a concise way of expressing side effects. And we can immediately test this logic, including the effects, without doing much additional work. +And that is enough to get something on the screen to play around with. It's definitely a few more +steps than if you were to do this in a vanilla SwiftUI way, but there are a few benefits. It gives +us a consistent manner to apply state mutations, instead of scattering logic in some observable +objects and in various action closures of UI components. It also gives us a concise way of +expressing side effects. And we can immediately test this logic, including the effects, without +doing much additional work. ### Testing > For more in-depth information on testing, see the dedicated [testing][testing-article] article. -To test use a `TestStore`, which can be created with the same information as the `Store`, but it does extra work to allow you to assert how your feature evolves as actions are sent: +To test use a `TestStore`, which can be created with the same information as the `Store`, but it +does extra work to allow you to assert how your feature evolves as actions are sent: ```swift @MainActor @@ -296,7 +345,9 @@ func testFeature() async { } ``` -Once the test store is created we can use it to make an assertion of an entire user flow of steps. Each step of the way we need to prove that state changed how we expect. For example, we can simulate the user flow of tapping on the increment and decrement buttons: +Once the test store is created we can use it to make an assertion of an entire user flow of steps. +Each step of the way we need to prove that state changed how we expect. For example, we can +simulate the user flow of tapping on the increment and decrement buttons: ```swift // Test that tapping on the increment/decrement buttons changes the count @@ -308,7 +359,9 @@ await store.send(.decrementButtonTapped) { } ``` -Further, if a step causes an effect to be executed, which feeds data back into the store, we must assert on that. For example, if we simulate the user tapping on the fact button we expect to receive a fact response back with the fact, which then causes the alert to show: +Further, if a step causes an effect to be executed, which feeds data back into the store, we must +assert on that. For example, if we simulate the user tapping on the fact button we expect to +receive a fact response back with the fact, which then causes the alert to show: ```swift await store.send(.numberFactButtonTapped) @@ -320,9 +373,13 @@ await store.receive(.numberFactResponse(.success(???))) { However, how do we know what fact is going to be sent back to us? -Currently our reducer is using an effect that reaches out into the real world to hit an API server, and that means we have no way to control its behavior. We are at the whims of our internet connectivity and the availability of the API server in order to write this test. +Currently our reducer is using an effect that reaches out into the real world to hit an API server, +and that means we have no way to control its behavior. We are at the whims of our internet +connectivity and the availability of the API server in order to write this test. -It would be better for this dependency to be passed to the reducer so that we can use a live dependency when running the application on a device, but use a mocked dependency for tests. We can do this by adding a property to the `Feature` reducer: +It would be better for this dependency to be passed to the reducer so that we can use a live +dependency when running the application on a device, but use a mocked dependency for tests. We can +do this by adding a property to the `Feature` reducer: ```swift struct Feature: ReducerProtocol { @@ -340,7 +397,8 @@ case .numberFactButtonTapped: } ``` -And in the entry point of the application we can provide a version of the dependency that actually interacts with the real world API server: +And in the entry point of the application we can provide a version of the dependency that actually +interacts with the real world API server: ```swift @main @@ -362,7 +420,8 @@ struct MyApp: App { } ``` -But in tests we can use a mock dependency that immediately returns a deterministic, predictable fact: +But in tests we can use a mock dependency that immediately returns a deterministic, predictable +fact: ```swift @MainActor @@ -376,7 +435,9 @@ func testFeature() async { } ``` -With that little bit of upfront work we can finish the test by simulating the user tapping on the fact button, receiving the response from the dependency to trigger the alert, and then dismissing the alert: +With that little bit of upfront work we can finish the test by simulating the user tapping on the +fact button, receiving the response from the dependency to trigger the alert, and then dismissing +the alert: ```swift await store.send(.numberFactButtonTapped) @@ -390,7 +451,11 @@ await store.send(.factAlertDismissed) { } ``` -We can also improve the ergonomics of using the `numberFact` dependency in our application. Over time the application may evolve into many features, and some of those features may also want access to `numberFact`, and explicitly passing it through all layers can get annoying. There is a process you can follow to “register” dependencies with the library, making them instantly available to any layer in the application. +We can also improve the ergonomics of using the `numberFact` dependency in our application. Over +time the application may evolve into many features, and some of those features may also want access +to `numberFact`, and explicitly passing it through all layers can get annoying. There is a process +you can follow to “register” dependencies with the library, making them instantly available to any +layer in the application. > For more in-depth information on dependency management, see the dedicated [dependencies][dependencies-article] article. @@ -425,7 +490,8 @@ extension DependencyValues { } ``` -With that little bit of upfront work done you can instantly start making use of the dependency in any feature: +With that little bit of upfront work done you can instantly start making use of the dependency in +any feature: ```swift struct Feature: ReducerProtocol { @@ -436,7 +502,10 @@ struct Feature: ReducerProtocol { } ``` -This code works exactly as it did before, but you no longer have to explicitly pass the dependency when constructing the feature's reducer. When running the app in previews, the simulator or on a device, the live dependency will be provided to the reducer, and in tests the test dependency will be provided. +This code works exactly as it did before, but you no longer have to explicitly pass the dependency +when constructing the feature's reducer. When running the app in previews, the simulator or on a +device, the live dependency will be provided to the reducer, and in tests the test dependency will +be provided. This means the entry point to the application no longer needs to construct dependencies: @@ -454,7 +523,8 @@ struct MyApp: App { } ``` -And the test store can be constructed without specifying any dependencies, but you can still override any dependency you need to for the purpose of the test: +And the test store can be constructed without specifying any dependencies, but you can still +override any dependency you need to for the purpose of the test: ```swift let store = TestStore( @@ -467,20 +537,24 @@ store.dependencies.numberFact.fetch = { "\($0) is a good number Brent" } … ``` -That is the basics of building and testing a feature in the Composable Architecture. There are _a lot_ more things to be explored, such as composition, modularity, adaptability, and complex effects. The [Examples](./Examples) directory has a bunch of projects to explore to see more advanced usages. +That is the basics of building and testing a feature in the Composable Architecture. There are +_a lot_ more things to be explored, such as composition, modularity, adaptability, and complex +effects. The [Examples](./Examples) directory has a bunch of projects to explore to see more +advanced usages. ## Documentation The documentation for releases and `main` are available here: * [`main`](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture) -* [0.47.0](https://pointfreeco.github.io/swift-composable-architecture/0.47.0/documentation/composablearchitecture/) +* [0.48.0](https://pointfreeco.github.io/swift-composable-architecture/0.48.0/documentation/composablearchitecture/)
Other versions - + + * [0.47.0](https://pointfreeco.github.io/swift-composable-architecture/0.47.0/documentation/composablearchitecture/) * [0.46.0](https://pointfreeco.github.io/swift-composable-architecture/0.46.0/documentation/composablearchitecture/) * [0.45.0](https://pointfreeco.github.io/swift-composable-architecture/0.45.0/documentation/composablearchitecture/) * [0.44.0](https://pointfreeco.github.io/swift-composable-architecture/0.44.0/documentation/composablearchitecture/) @@ -493,7 +567,8 @@ The documentation for releases and `main` are available here:
-There are a number of articles in the documentation that you may find helpful as you become more comfortable with the library: +There are a number of articles in the documentation that you may find helpful as you become more +comfortable with the library: * [Getting started][getting-started-article] * [Dependency management][dependencies-article] @@ -508,14 +583,23 @@ There are a number of articles in the documentation that you may find helpful as You can add ComposableArchitecture to an Xcode project by adding it as a package dependency. 1. From the **File** menu, select **Add Packages...** - 2. Enter "https://github.com/pointfreeco/swift-composable-architecture" into the package repository URL text field + 2. Enter "https://github.com/pointfreeco/swift-composable-architecture" into the package + repository URL text field 3. Depending on how your project is structured: - - If you have a single application target that needs access to the library, then add **ComposableArchitecture** directly to your application. - - If you want to use this library from multiple Xcode targets, or mix Xcode targets and SPM targets, you must create a shared framework that depends on **ComposableArchitecture** and then depend on that framework in all of your targets. For an example of this, check out the [Tic-Tac-Toe](./Examples/TicTacToe) demo application, which splits lots of features into modules and consumes the static library in this fashion using the **tic-tac-toe** Swift package. + - If you have a single application target that needs access to the library, then add + **ComposableArchitecture** directly to your application. + - If you want to use this library from multiple Xcode targets, or mix Xcode targets and SPM + targets, you must create a shared framework that depends on **ComposableArchitecture** and + then depend on that framework in all of your targets. For an example of this, check out the + [Tic-Tac-Toe](./Examples/TicTacToe) demo application, which splits lots of features into + modules and consumes the static library in this fashion using the **tic-tac-toe** Swift + package. ## Help -If you want to discuss the Composable Architecture or have a question about how to use it to solve a particular problem, you can start a topic in the [discussions][gh-discussions] tab of this repo, or ask around on [its Swift forum][swift-forum]. +If you want to discuss the Composable Architecture or have a question about how to use it to solve +a particular problem, you can start a topic in the [discussions][gh-discussions] tab of this repo, +or ask around on [its Swift forum][swift-forum]. ## Translations @@ -531,37 +615,61 @@ The following translations of this README have been contributed by members of th * [Simplified Chinese](https://gist.github.com/sh3l6orrr/10c8f7c634a892a9c37214f3211242ad) * [Spanish](https://gist.github.com/pitt500/f5e32fccb575ce112ffea2827c7bf942) -If you'd like to contribute a translation, please [open a PR](https://github.com/pointfreeco/swift-composable-architecture/edit/main/README.md) with a link to a [Gist](https://gist.github.com)! +If you'd like to contribute a translation, please [open a +PR](https://github.com/pointfreeco/swift-composable-architecture/edit/main/README.md) with a link +to a [Gist](https://gist.github.com)! ## FAQ * How does the Composable Architecture compare to Elm, Redux, and others?
Expand to see answer - The Composable Architecture (TCA) is built on a foundation of ideas popularized by the Elm Architecture (TEA) and Redux, but made to feel at home in the Swift language and on Apple's platforms. - - In some ways TCA is a little more opinionated than the other libraries. For example, Redux is not prescriptive with how one executes side effects, but TCA requires all side effects to be modeled in the `Effect` type and returned from the reducer. - - In other ways TCA is a little more lax than the other libraries. For example, Elm controls what kinds of effects can be created via the `Cmd` type, but TCA allows an escape hatch to any kind of effect since `Effect` conforms to the Combine `Publisher` protocol. - - And then there are certain things that TCA prioritizes highly that are not points of focus for Redux, Elm, or most other libraries. For example, composition is very important aspect of TCA, which is the process of breaking down large features into smaller units that can be glued together. This is accomplished with reducer builders and operators like `Scope`, and it aids in handling complex features as well as modularization for a better-isolated code base and improved compile times. + The Composable Architecture (TCA) is built on a foundation of ideas popularized by the Elm + Architecture (TEA) and Redux, but made to feel at home in the Swift language and on Apple's + platforms. + + In some ways TCA is a little more opinionated than the other libraries. For example, Redux is + not prescriptive with how one executes side effects, but TCA requires all side effects to be + modeled in the `Effect` type and returned from the reducer. + + In other ways TCA is a little more lax than the other libraries. For example, Elm controls what + kinds of effects can be created via the `Cmd` type, but TCA allows an escape hatch to any kind + of effect since `Effect` conforms to the Combine `Publisher` protocol. + + And then there are certain things that TCA prioritizes highly that are not points of focus for + Redux, Elm, or most other libraries. For example, composition is very important aspect of TCA, + which is the process of breaking down large features into smaller units that can be glued + together. This is accomplished with reducer builders and operators like `Scope`, and it aids in + handling complex features as well as modularization for a better-isolated code base and improved + compile times.
## Credits and thanks -The following people gave feedback on the library at its early stages and helped make the library what it is today: +The following people gave feedback on the library at its early stages and helped make the library +what it is today: -Paul Colton, Kaan Dedeoglu, Matt Diephouse, Josef Doležal, Eimantas, Matthew Johnson, George Kaimakas, Nikita Leonov, Christopher Liscio, Jeffrey Macko, Alejandro Martinez, Shai Mishali, Willis Plummer, Simon-Pierre Roy, Justin Price, Sven A. Schmidt, Kyle Sherman, Petr Šíma, Jasdev Singh, Maxim Smirnov, Ryan Stone, Daniel Hollis Tavares, and all of the [Point-Free][pointfreeco] subscribers 😁. +Paul Colton, Kaan Dedeoglu, Matt Diephouse, Josef Doležal, Eimantas, Matthew Johnson, George +Kaimakas, Nikita Leonov, Christopher Liscio, Jeffrey Macko, Alejandro Martinez, Shai Mishali, Willis +Plummer, Simon-Pierre Roy, Justin Price, Sven A. Schmidt, Kyle Sherman, Petr Šíma, Jasdev Singh, +Maxim Smirnov, Ryan Stone, Daniel Hollis Tavares, and all of the [Point-Free][pointfreeco] +subscribers 😁. -Special thanks to [Chris Liscio](https://twitter.com/liscio) who helped us work through many strange SwiftUI quirks and helped refine the final API. +Special thanks to [Chris Liscio](https://twitter.com/liscio) who helped us work through many strange +SwiftUI quirks and helped refine the final API. -And thanks to [Shai Mishali](https://github.com/freak4pc) and the [CombineCommunity](https://github.com/CombineCommunity/CombineExt/) project, from which we took their implementation of `Publishers.Create`, which we use in `Effect` to help bridge delegate and callback-based APIs, making it much easier to interface with 3rd party frameworks. +And thanks to [Shai Mishali](https://github.com/freak4pc) and the +[CombineCommunity](https://github.com/CombineCommunity/CombineExt/) project, from which we took +their implementation of `Publishers.Create`, which we use in `Effect` to help bridge delegate and +callback-based APIs, making it much easier to interface with 3rd party frameworks. ## Other libraries -The Composable Architecture was built on a foundation of ideas started by other libraries, in particular [Elm](https://elm-lang.org) and [Redux](https://redux.js.org/). +The Composable Architecture was built on a foundation of ideas started by other libraries, in +particular [Elm](https://elm-lang.org) and [Redux](https://redux.js.org/). -There are also many architecture libraries in the Swift and iOS community. Each one of these has their own set of priorities and trade-offs that differ from the Composable Architecture. +There are also many architecture libraries in the Swift and iOS community. Each one of these has +their own set of priorities and trade-offs that differ from the Composable Architecture. * [RIBs](https://github.com/uber/RIBs) * [Loop](https://github.com/ReactiveCocoa/Loop) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/DependencyManagement.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/DependencyManagement.md index a5576234ff9f..6d457d16d6bf 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/DependencyManagement.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/DependencyManagement.md @@ -15,443 +15,16 @@ alter the execution context a feature runs in. This means in tests and Xcode pre provide a mock version of an API client that immediately returns some stubbed data rather than making a live network request to a server. -* [The need for controlled dependencies](#The-need-for-controlled-dependencies) -* [Using library dependencies](#Using-library-dependencies) -* [Registering your own dependencies](#Registering-your-own-dependencies) -* [Live, preview and test dependencies](#Live-preview-and-test-dependencies) -* [Designing dependencies](#Designing-dependencies) -* [Overriding dependencies](#Overriding-dependencies) - -## The need for controlled dependencies - -Suppose that you are building a todo application with a `Todo` model that has a UUID identifier: - -```swift -struct Todo: Equatable, Identifiable { - let id: UUID - var title = "" - var isCompleted = false -} -``` - -And suppose you have a reducer that handles an action for when the "Add todo" button is tapped, -which appends a new todo to the end of the array: - -```swift -struct Todos: ReducerProtocol { - struct State { - var todos: IdentifiedArrayOf = [] - // ... - } - enum Action { - case addButtonTapped - // ... - } - - func reduce(into state: inout State, action: Action) -> EffectTask { - switch action { - case .addButtonTapped: - state.todos.append(Todo(id: UUID()) - return .none - - // ... - } - } -} -``` - -> Tip: We are using `IdentifiedArray` from our -[Identified Collections][swift-identified-collections] library because it provides a safe and -ergonomic API for accessing elements from a stable ID rather than positional indices. - -In the reducer we are using the uncontrolled `UUID` initializer from Foundation. Every invocation -of the initializer produces a fully random UUID. That may seem like what we want, but unfortunately -it wreaks havoc on our ability to test. - -If we tried writing a test for the add todo functionality we will quickly find that we can't -possibly predict what UUID will be produced for the new todo: - -```swift -@MainActor -func testAddTodo() async { - let store = TestStore( - initialState: Todos.State(), - reducer: Todos() - ) - - await store.send(.addButtonTapped) { - $0.todos = [ - Todo(id: ???) - ] - } -} -``` - -> Tip: Read the article to learn how to write tests for state mutations and effect -> execution in your features. - -There is no way to get this test to pass. - -This is why controlling dependencies is important. It allows us to substitute a UUID generator that -is deterministic in tests, such as one that simply increments by 1 every time it is invoked. - -The library comes with a controlled UUID generator and can be accessed by using the -`@Dependency` property wrapper to add a dependency to the `Todos` reducer: - -```swift -struct Todos: ReducerProtocol { - @Dependency(\.uuid) var uuid - // ... -} -``` - -Then when you need a new UUID you should reach for the dependency rather than reaching for the -uncontrollable UUID initializer: - -```swift -case .addButtonTapped: - state.todos.append(Todo(id: self.uuid()) // ⬅️ - return .none -``` - -If you do this little bit of upfront work you instantly unlock the ability to test the feature by -providing a controlled, deterministic version of the UUID generator in tests. The library even comes -with such a version for the UUID generator, and it is called `incrementing`. You can override -the dependency directly on the ``TestStore`` so that your feature's reducer uses that version -instead of the live one: - -```swift -@MainActor -func testAddTodo() async { - let store = TestStore( - initialState: Todos.State(), - reducer: Todos() - ) - - store.dependencies.uuid = .incrementing - - await store.send(.addButtonTapped) { - $0.todos = [ - Todo(id: UUID(string: "00000000-0000-0000-0000-000000000000")!) - ] - } -} -``` - -This test will pass deterministically, 100% of the time, and this is why it is so important to -control dependencies that interact with outside systems. - -## Using library dependencies - -The library comes with many common dependencies that can be used in a controllable manner, such as -date generators, clocks, random number generators, UUID generators, and more. - -For example, suppose you have a feature that needs access to a date initializer, the continuous -clock for time-based asynchrony, and a UUID initializer. All 3 dependencies can be added to your -feature's reducer: - -```swift -struct Todos: ReducerProtocol { - struct State { - // ... - } - enum Action { - // ... - } - @Dependency(\.date) var date - @Dependency(\.continuousClock) var clock - @Dependency(\.uuid) var uuid - - // ... -} -``` - -Then, all 3 dependencies can easily be overridden with deterministic versions when testing the -feature: - -```swift -@MainActor -func testTodos() async { - let store = TestStore( - initialState: Todos.State(), - reducer: Todos() - ) - - store.dependencies.date = .constant(Date(timeIntervalSinceReferenceDate: 1234567890)) - store.dependencies.continuousClock = ImmediateClock() - store.dependencies.uuid = .incrementing - - // ... -} -``` - -## Registering your own dependencies - -Although the library comes with many controllable dependencies out of the box, there are still -times when you want to register your own dependencies with the library so that you can use the -`@Dependency` property wrapper. Doing this is quite similar to registering an -[environment value][environment-values-docs] in SwiftUI. - -First you create a type that conforms to the `DependencyKey` protocol. The minimum implementation -you must provide is a `liveValue`, which is the value used when running the app in a simulator or -on device, and so it's appropriate for it to actually make network requests to an external server: - -```swift -private enum APIClientKey: DependencyKey { - static let liveValue = APIClient.live -} -``` - -> Tip: There are two other values you can provide for a dependency. If you implement `testValue` -it will be used when testing features in a ``TestStore``, and if you implement `previewValue` it -will be used while running features in an Xcode preview. You don't need to worry about those -values when you are just getting started, and instead can -[add them later](#Live-preview-and-test-dependencies). - -Finally, an extension must be made to `DependencyValues` to expose a computed property for the -dependency: - -```swift -extension DependencyValues { - var apiClient: APIClient { - get { self[APIClientKey.self] } - set { self[APIClientKey.self] = newValue } - } -} -``` - -With those few steps completed you can instantly access your API client dependency from any -feature's reducer by using the `@Dependency` property wrapper: - -```swift -struct Todos: ReducerProtocol { - @Dependency(\.apiClient) var apiClient - // ... -} -``` - -This will automatically use the live dependency in previews, simulators and devices, and in -tests you can override any endpoint of the dependency to return mock data: - -```swift -@MainActor -func testFetchUser() async { - let store = TestStore( - initialState: Todos.State(), - reducer: Todos() - ) - - store.dependencies.apiClient.fetchUser = { _ in User(id: 1, name: "Blob") } - - await store.send(.loadButtonTapped) - await store.receive(.userResponse(.success(User(id: 1, name: "Blob")))) { - $0.loadedUser = User(id: 1, name: "Blob") - } -} -``` - -Often times it is not necessary to create a whole new type to conform to `DependencyKey`. If the -dependency you are registering is a type that you own, then you can conform it directly to the -protocol: - -```swift -extension APIClient: DependencyKey { - static let liveValue = APIClient.live -} - -extension DependencyValues { - var apiClient: APIClient { - get { self[APIClient.self] } - set { self[APIClient.self] = newValue } - } -} -``` - -That can save a little bit of boilerplate. - -## Live, preview and test dependencies - -In the previous section we showed that to conform to `DependencyKey` you must provide _at least_ -a `liveValue`, which is the default version of the dependency that is used when running on a -device or simulator. The `DependencyKey` protocol inherits from a base protocol, -`TestDependencyKey`, which has 2 other requirements, `testValue` and `previewValue`. Both are -optional and delegate to `liveValue` if not implemented. - -If you implement a static `testValue` property on your key, that value will be used when running -your feature in a ``TestStore``. This is a great opportunity to supply a mocked version of the -dependency that does not reach out to the real world. By doing this you can guarantee that your -tests will never accidentally make a network request, or track analytics events that are not -actually tied to user actions, and more. - -Further, we highly recommend you consider making your `testValue` dependency into what we like to -call an "unimplemented" dependency. This is a version of your dependency that performs an `XCTFail` -in each endpoint so that if it is ever invoked in tests it will cause a test failure. This allows -you to be more explicit about what dependencies are actually needed to test a particular user -flow in your feature. - -For example, suppose you have an API client with endpoints for fetching a list of users or fetching -a particular user by id: - -```swift -struct APIClient { - var fetchUser: (User.ID) async throws -> User - var fetchUsers: () async throws -> [User] -} -``` - -Then we can construct an "unimplemented" version of this dependency that invokes `XCTFail` when -any endpoint is invoked - -```swift -extension APIClient { - static let unimplemented = Self( - fetchUser: { _ in XCTFail("APIClient.fetchUser unimplemented") } - fetchUsers: { XCTFail("APIClient.fetchUsers unimplemented") } - ) -} -``` - -Unfortunately, `XCTFail` cannot be used in non-test targets, and so this instance cannot be defined -in the same file where your dependency is registered. To work around this you can use our -[XCTestDynamicOverlay][xctest-dynamic-overlay-gh] library that dynamically invokes `XCTFail` and -it is automatically accessible when using the Composable Architecture. It also comes with some -helpers to ease the construction of these unimplemented values, which we can use when defining the -`testValue` of your dependency: - -```swift -import XCTestDynamicOverlay - -extension APIClient { - static let testValue = Self( - fetchUser: unimplemented("APIClient.fetchUser") - fetchUsers: unimplemented("APIClient.fetchUsers") - ) -} -``` - -The other requirement of `TestDependencyKey` is `previewValue`, and if this value is implemented -it will be used whenever your feature is run in an Xcode preview. Previews are similar to tests in -that you usually do not want to interact with the outside world, such as making network requests. -In fact, many of Apple's frameworks do not work in previews, such as Core Location, and so it will -be hard to interact with your feature in previews if it touches those frameworks. - -However, previews are dissimilar to tests in that it's fine for dependencies to return some mock -data. There's no need to deal with "unimplemented" clients for proving which dependencies are -actually used. - -For the `APIClient` example from above, we might define its `previewValue` like so: - -```swift -extension APIClient: TestDependencyKey { - static let previewValue = Self( - fetchUsers: { - [ - User(id: 1, name: "Blob"), - User(id: 1, name: "Blob Jr."), - User(id: 1, name: "Blob Sr."), - ] - }, - fetchUser: { id in - User(id: id, name: "Blob, id: \(id)") - } - ) -} -``` - -Then when running a feature that uses this dependency in an Xcode preview will immediately get -data provided to it, making it easier for you to iterate on your feature's logic and styling. - -## Designing dependencies - -Making it possible to control your dependencies is the most important step you can take towards -making your features isolatable and testable. The second most important step after that is to -design your dependencies in a way that maximizes their flexibility in tests and other situations. - -The most popular way to design dependencies in Swift is to use protocols. For example, if your -feature needs to interact with an audio player, you might design a protocol with methods for -playing, stopping, and more: - -```swift -protocol AudioPlayer { - func loop(_ url: URL) async throws - func play(_ url: URL) async throws - func setVolume(_ volume: Float) async - func stop() async -} -``` - -Then you are free to make as many conformances of this protocol as you want, such as a -`LiveAudioPlayer` that actually interacts with AVFoundation, or a `MockAudioPlayer` that doesn't -play any sounds, but does suspend in order to simulate that something is playing. You could even -have an `UnimplementedAudioPlayer` conformance that invokes `XCTFail` when any method is invoked. -And all of those conformances can be used to specify the live, preview and test values for the -dependency: - -```swift -private enum AudioPlayerKey: DependencyKey { - static let liveValue: any AudioPlayer = LiveAudioPlayer() - static let previewValue: any AudioPlayer = MockAudioPlayer() - static let testValue: any AudioPlayer = UnimplementedAudioPlayer() -} -``` - -This style of dependencies works just fine, and if it is what you are most comfortable with then -there is no need to change. - -However, there is a small change one can make to this dependency to unlock even more power. Rather -than designing the audio player as a protocol, we can use a struct with closure properties to -represent the interface: - -```swift -struct AudioPlayerClient { - var loop: (_ url: URL) async throws -> Void - var play: (_ url: URL) async throws -> Void - var setVolume: (_ volume: Float) async -> Void - var stop: () async -> Void -} -``` - -Then, rather than defining types that conform to the protocol you construct values: - -```swift -extension AudioPlayerClient { - static let live = Self(…) - static let mock = Self(…) - static let unimplemented = Self(…) -} -``` - -And to register the dependency you can leverage the struct that defines the interface. There's no -need to define a new type: - -```swift -extension AudioPlayerClient: DependencyKey { - static let liveValue = AudioPlayerClient.live - static let previewValue = AudioPlayerClient.mock - static let testValue = AudioPlayerClient.unimplemented -} -``` - -If you design your dependencies in this way you can pick which dependency endpoints you need in your -feature. For example, if you have a feature that needs an audio player to do its job, but it only -needs the `play` endpoint, and doesn't need to loop, set volume or stop audio, then you can specify -a dependency on just that one function: - -```swift -struct Feature: ReducerProtocol { - @Dependency(\.audioPlayer.play) var play - // … -} -``` - -This can allow your features to better describe the minimal interface they need from dependencies, -which can help a feature to seem less intimidating. +> Note: The dependency management system in the Composable Architecture is driven off of our +> [Dependencies][swift-dependencies-gh] library. That repository has extensive +> [documentation][swift-deps-docs] and articles, and we highly recommend you familiarize yourself +> with all of that content to best leverage dependencies. ## Overriding dependencies It is possible to change the dependencies for just one particular reducer inside a larger composed -reducer. This can be handy when running a feature in a more controlled environment where it may not be -appropriate to communicate with the outside world. +reducer. This can be handy when running a feature in a more controlled environment where it may not +be appropriate to communicate with the outside world. For example, suppose you want to teach users how to use your feature through an onboarding experience. In such an experience it may not be appropriate for the user's actions to cause @@ -481,3 +54,5 @@ as any reducer `Feature` uses under the hood, _and_ any effects produced by `Fea [swift-identified-collections]: https://github.com/pointfreeco/swift-identified-collections [environment-values-docs]: https://developer.apple.com/documentation/swiftui/environmentvalues [xctest-dynamic-overlay-gh]: http://github.com/pointfreeco/xctest-dynamic-overlay +[swift-dependencies-gh]: http://github.com/pointfreeco/swift-dependencies +[swift-deps-docs]: https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/ diff --git a/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md b/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md index f3cdd2a635c5..b899e4932351 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md +++ b/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md @@ -73,7 +73,6 @@ day-to-day when building applications, such as: - - ``TestStore`` -- ``ActorIsolated`` ## See Also diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md index 8608df50d55e..f255cf3f4c46 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md @@ -18,7 +18,7 @@ - ``receive(_:timeout:assert:file:line:)-1rwdd`` - ``receive(_:timeout:assert:file:line:)-4e4m0`` - ``receive(_:timeout:assert:file:line:)-3myco`` -- ``finish(timeout:file:line:)-53gi5`` +- ``finish(timeout:file:line:)`` - ``TestStoreTask`` ### Methods for skipping actions and effects diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index 4f6e7ecd4c03..a65dc46c322e 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -175,39 +175,41 @@ extension EffectPublisher where Failure == Never { fileID: StaticString = #fileID, line: UInt = #line ) -> Self { - let dependencies = DependencyValues._current - return Self( - operation: .run(priority) { send in - await DependencyValues.$_current.withValue(dependencies) { - do { - try await send(operation()) - } catch is CancellationError { - return - } catch { - guard let handler = handler else { - #if DEBUG - var errorDump = "" - customDump(error, to: &errorDump, indent: 4) - runtimeWarn( - """ - An "EffectTask.task" returned from "\(fileID):\(line)" threw an unhandled error. … - - \(errorDump) - - All non-cancellation errors must be explicitly handled via the "catch" parameter \ - on "EffectTask.task", or via a "do" block. - """, - file: file, - line: line - ) - #endif + withEscapedDependencies { escaped in + Self( + operation: .run(priority) { send in + await escaped.yield { + do { + try await send(operation()) + } catch is CancellationError { return + } catch { + guard let handler = handler else { + #if DEBUG + var errorDump = "" + customDump(error, to: &errorDump, indent: 4) + runtimeWarn( + """ + An "EffectTask.task" returned from "\(fileID):\(line)" threw an unhandled \ + error. … + + \(errorDump) + + All non-cancellation errors must be explicitly handled via the "catch" \ + parameter on "EffectTask.task", or via a "do" block. + """, + file: file, + line: line + ) + #endif + return + } + await send(handler(error)) } - await send(handler(error)) } } - } - ) + ) + } } /// Wraps an asynchronous unit of work that can emit any number of times in an effect. @@ -257,39 +259,40 @@ extension EffectPublisher where Failure == Never { fileID: StaticString = #fileID, line: UInt = #line ) -> Self { - let dependencies = DependencyValues._current - return Self( - operation: .run(priority) { send in - await DependencyValues.$_current.withValue(dependencies) { - do { - try await operation(send) - } catch is CancellationError { - return - } catch { - guard let handler = handler else { - #if DEBUG - var errorDump = "" - customDump(error, to: &errorDump, indent: 4) - runtimeWarn( - """ - An "EffectTask.run" returned from "\(fileID):\(line)" threw an unhandled error. … - - \(errorDump) - - All non-cancellation errors must be explicitly handled via the "catch" parameter \ - on "EffectTask.run", or via a "do" block. - """, - file: file, - line: line - ) - #endif + withEscapedDependencies { escaped in + Self( + operation: .run(priority) { send in + await escaped.yield { + do { + try await operation(send) + } catch is CancellationError { return + } catch { + guard let handler = handler else { + #if DEBUG + var errorDump = "" + customDump(error, to: &errorDump, indent: 4) + runtimeWarn( + """ + An "EffectTask.run" returned from "\(fileID):\(line)" threw an unhandled error. … + + \(errorDump) + + All non-cancellation errors must be explicitly handled via the "catch" parameter \ + on "EffectTask.run", or via a "do" block. + """, + file: file, + line: line + ) + #endif + return + } + await handler(error, send) } - await handler(error, send) } } - } - ) + ) + } } /// Creates an effect that executes some work in the real world that doesn't need to feed data @@ -500,23 +503,33 @@ extension EffectPublisher { case .none: return .none case let .publisher(publisher): - let dependencies = DependencyValues._current - let transform = { action in - DependencyValues.$_current.withValue(dependencies) { - transform(action) - } - } - return .init(operation: .publisher(publisher.map(transform).eraseToAnyPublisher())) - case let .run(priority, operation): return .init( - operation: .run(priority) { send in - await operation( - Send { action in - send(transform(action)) + operation: .publisher( + publisher + .map(withEscapedDependencies { escaped in + { action in + escaped.yield { + transform(action) + } + } + }) + .eraseToAnyPublisher() + ) + ) + case let .run(priority, operation): + return withEscapedDependencies { escaped in + .init( + operation: .run(priority) { send in + await escaped.yield { + await operation( + Send { action in + send(transform(action)) + } + ) + } } ) - } - ) + } } } } @@ -651,7 +664,7 @@ extension EffectPublisher { message: """ 'Effect' has been deprecated in favor of 'EffectTask' when 'Failure == Never', or 'EffectPublisher' in general. - + You are encouraged to use 'EffectTask' to model the output of your reducers, and to use Swift concurrency to model failable streams of values. To find and replace instances of 'Effect' to 'EffectTask' in your codebase, use the following regular expression: diff --git a/Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift b/Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift deleted file mode 100644 index f40defdf2f21..000000000000 --- a/Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift +++ /dev/null @@ -1,422 +0,0 @@ -extension AsyncStream { - /// Initializes an `AsyncStream` from any `AsyncSequence`. - /// - /// Useful as a type eraser for live `AsyncSequence`-based dependencies. - /// - /// For example, your feature may want to subscribe to screenshot notifications. You can model - /// this as a dependency client that returns an `AsyncStream`: - /// - /// ```swift - /// struct ScreenshotsClient { - /// var screenshots: () -> AsyncStream - /// func callAsFunction() -> AsyncStream { self.screenshots() } - /// } - /// ``` - /// - /// The "live" implementation of the dependency can supply a stream by erasing the appropriate - /// `NotificationCenter.Notifications` async sequence: - /// - /// ```swift - /// extension ScreenshotsClient { - /// static let live = Self( - /// screenshots: { - /// AsyncStream( - /// NotificationCenter.default - /// .notifications(named: UIApplication.userDidTakeScreenshotNotification) - /// .map { _ in } - /// ) - /// } - /// ) - /// } - /// ``` - /// - /// While your tests can use `AsyncStream.streamWithContinuation` to spin up a controllable stream - /// for tests: - /// - /// ```swift - /// let screenshots = AsyncStream.streamWithContinuation() - /// - /// let store = TestStore( - /// initialState: Feature.State(), - /// reducer: Feature() - /// ) - /// - /// store.dependencies.screenshots.screenshots = { screenshots.stream } - /// - /// screenshots.continuation.yield() // Simulate a screenshot being taken. - /// - /// await store.receive(.screenshotTaken) { ... } - /// ``` - /// - /// - Parameters: - /// - sequence: An `AsyncSequence`. - /// - limit: The maximum number of elements to hold in the buffer. By default, this value is - /// unlimited. Use a `Continuation.BufferingPolicy` to buffer a specified number of oldest or - /// newest elements. - public init( - _ sequence: S, - bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded - ) where S.Element == Element { - self.init(bufferingPolicy: limit) { (continuation: Continuation) in - let task = Task { - do { - for try await element in sequence { - continuation.yield(element) - } - } catch {} - continuation.finish() - } - continuation.onTermination = - { _ in - task.cancel() - } - // NB: This explicit cast is needed to work around a compiler bug in Swift 5.5.2 - as @Sendable (Continuation.Termination) -> Void - } - } - - /// Constructs and returns a stream along with its backing continuation. - /// - /// This is handy for immediately escaping the continuation from an async stream, which typically - /// requires multiple steps: - /// - /// ```swift - /// var _continuation: AsyncStream.Continuation! - /// let stream = AsyncStream { continuation = $0 } - /// let continuation = _continuation! - /// - /// // vs. - /// - /// let (stream, continuation) = AsyncStream.streamWithContinuation() - /// ``` - /// - /// This tool is usually used for tests where we need to supply an async sequence to a dependency - /// endpoint and get access to its continuation so that we can emulate the dependency - /// emitting data. For example, suppose you have a dependency exposing an async sequence for - /// listening to notifications. To test this you can use `streamWithContinuation`: - /// - /// ```swift - /// let notifications = AsyncStream.streamWithContinuation() - /// - /// let store = TestStore( - /// initialState: Feature.State(), - /// reducer: Feature() - /// ) - /// - /// store.dependencies.notifications = { notifications.stream } - /// - /// await store.send(.task) - /// notifications.continuation.yield("Hello") // Simulate notification being posted - /// await store.receive(.notification("Hello")) { - /// $0.message = "Hello" - /// } - /// ``` - /// - /// > Warning: ⚠️ `AsyncStream` does not support multiple subscribers, therefore you can only use - /// > this helper to test features that do not subscribe multiple times to the dependency - /// > endpoint. - /// - /// - Parameters: - /// - elementType: The type of element the `AsyncStream` produces. - /// - limit: A Continuation.BufferingPolicy value to set the stream’s buffering behavior. By - /// default, the stream buffers an unlimited number of elements. You can also set the policy to - /// buffer a specified number of oldest or newest elements. - /// - Returns: An `AsyncStream`. - public static func streamWithContinuation( - _ elementType: Element.Type = Element.self, - bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded - ) -> (stream: Self, continuation: Continuation) { - var continuation: Continuation! - return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) - } - - /// An `AsyncStream` that never emits and never completes unless cancelled. - public static var never: Self { - Self { _ in } - } - - public static var finished: Self { - Self { $0.finish() } - } -} - -extension AsyncThrowingStream where Failure == Error { - /// Initializes an `AsyncThrowingStream` from any `AsyncSequence`. - /// - /// - Parameters: - /// - sequence: An `AsyncSequence`. - /// - limit: The maximum number of elements to hold in the buffer. By default, this value is - /// unlimited. Use a `Continuation.BufferingPolicy` to buffer a specified number of oldest or - /// newest elements. - public init( - _ sequence: S, - bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded - ) where S.Element == Element { - self.init(bufferingPolicy: limit) { (continuation: Continuation) in - let task = Task { - do { - for try await element in sequence { - continuation.yield(element) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = - { _ in - task.cancel() - } - // NB: This explicit cast is needed to work around a compiler bug in Swift 5.5.2 - as @Sendable (Continuation.Termination) -> Void - } - } - - /// Constructs and returns a stream along with its backing continuation. - /// - /// This is handy for immediately escaping the continuation from an async stream, which typically - /// requires multiple steps: - /// - /// ```swift - /// var _continuation: AsyncThrowingStream.Continuation! - /// let stream = AsyncThrowingStream { continuation = $0 } - /// let continuation = _continuation! - /// - /// // vs. - /// - /// let (stream, continuation) = AsyncThrowingStream.streamWithContinuation() - /// ``` - /// - /// This tool is usually used for tests where we need to supply an async sequence to a dependency - /// endpoint and get access to its continuation so that we can emulate the dependency - /// emitting data. For example, suppose you have a dependency exposing an async sequence for - /// listening to notifications. To test this you can use `streamWithContinuation`: - /// - /// ```swift - /// let notifications = AsyncThrowingStream.streamWithContinuation() - /// - /// let store = TestStore( - /// initialState: Feature.State(), - /// reducer: Feature() - /// ) - /// - /// store.dependencies.notifications = { notifications.stream } - /// - /// await store.send(.task) - /// notifications.continuation.yield("Hello") // Simulate a notification being posted - /// await store.receive(.notification("Hello")) { - /// $0.message = "Hello" - /// } - /// ``` - /// - /// > Warning: ⚠️ `AsyncStream` does not support multiple subscribers, therefore you can only use - /// > this helper to test features that do not subscribe multiple times to the dependency - /// > endpoint. - /// - /// - Parameters: - /// - elementType: The type of element the `AsyncThrowingStream` produces. - /// - limit: A Continuation.BufferingPolicy value to set the stream’s buffering behavior. By - /// default, the stream buffers an unlimited number of elements. You can also set the policy to - /// buffer a specified number of oldest or newest elements. - /// - Returns: An `AsyncThrowingStream`. - public static func streamWithContinuation( - _ elementType: Element.Type = Element.self, - bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded - ) -> (stream: Self, continuation: Continuation) { - var continuation: Continuation! - return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) - } - - /// An `AsyncThrowingStream` that never emits and never completes unless cancelled. - public static var never: Self { - Self { _ in } - } - - public static var finished: Self { - Self { $0.finish() } - } -} - -extension Task where Failure == Never { - /// An async function that never returns. - public static func never() async throws -> Success { - for await element in AsyncStream.never { - return element - } - throw _Concurrency.CancellationError() - } -} - -extension Task where Success == Never, Failure == Never { - /// An async function that never returns. - public static func never() async throws { - for await _ in AsyncStream.never {} - throw _Concurrency.CancellationError() - } -} - -/// A generic wrapper for isolating a mutable value to an actor. -/// -/// This type is most useful when writing tests for when you want to inspect what happens inside -/// an effect. For example, suppose you have a feature such that when a button is tapped you -/// track some analytics: -/// -/// ```swift -/// @Dependency(\.analytics) var analytics -/// -/// func reduce(into state: inout State, action: Action) -> EffectTask { -/// switch action { -/// case .buttonTapped: -/// return .fireAndForget { try await self.analytics.track("Button Tapped") } -/// } -/// } -/// ``` -/// -/// Then, in tests we can construct an analytics client that appends events to a mutable array -/// rather than actually sending events to an analytics server. However, in order to do this in -/// a safe way we should use an actor, and ``ActorIsolated`` makes this easy: -/// -/// ```swift -/// @MainActor -/// func testAnalytics() async { -/// let store = TestStore(…) -/// -/// let events = ActorIsolated<[String]>([]) -/// store.dependencies.analytics = AnalyticsClient( -/// track: { event in -/// await events.withValue { $0.append(event) } -/// } -/// ) -/// -/// await store.send(.buttonTapped) -/// -/// await events.withValue { XCTAssertEqual($0, ["Button Tapped"]) } -/// } -/// ``` -@dynamicMemberLookup -public final actor ActorIsolated { - /// The actor-isolated value. - public var value: Value - - /// Initializes actor-isolated state around a value. - /// - /// - Parameter value: A value to isolate in an actor. - public init(_ value: Value) { - self.value = value - } - - public subscript(dynamicMember keyPath: KeyPath) -> Subject { - self.value[keyPath: keyPath] - } - - /// Perform an operation with isolated access to the underlying value. - /// - /// Useful for inspecting an actor-isolated value for a test assertion: - /// - /// ```swift - /// let didOpenSettings = ActorIsolated(false) - /// store.dependencies.openSettings = { await didOpenSettings.setValue(true) } - /// - /// await store.send(.settingsButtonTapped) - /// - /// await didOpenSettings.withValue { XCTAssertTrue($0) } - /// ``` - /// - /// - Parameters: operation: An operation to be performed on the actor with the underlying value. - /// - Returns: The result of the operation. - public func withValue( - _ operation: @Sendable (inout Value) throws -> T - ) rethrows -> T { - var value = self.value - defer { self.value = value } - return try operation(&value) - } - - /// Overwrite the isolated value with a new value. - /// - /// Useful for setting an actor-isolated value when a tested dependency runs. - /// - /// ```swift - /// let didOpenSettings = ActorIsolated(false) - /// store.dependencies.openSettings = { await didOpenSettings.setValue(true) } - /// - /// await store.send(.settingsButtonTapped) - /// - /// await didOpenSettings.withValue { XCTAssertTrue($0) } - /// ``` - /// - /// - Parameter newValue: The value to replace the current isolated value with. - public func setValue(_ newValue: Value) { - self.value = newValue - } -} - -/// A generic wrapper for turning any non-`Sendable` type into a `Sendable` one, in an unchecked -/// manner. -/// -/// Sometimes we need to use types that should be sendable but have not yet been audited for -/// sendability. If we feel confident that the type is truly sendable, and we don't want to blanket -/// disable concurrency warnings for a module via `@preconcurrency import`, then we can selectively -/// make that single type sendable by wrapping it in ``UncheckedSendable``. -/// -/// > Note: By wrapping something in ``UncheckedSendable`` you are asking the compiler to trust -/// you that the type is safe to use from multiple threads, and the compiler cannot help you find -/// potential race conditions in your code. -@dynamicMemberLookup -@propertyWrapper -public struct UncheckedSendable: @unchecked Sendable { - /// The unchecked value. - public var value: Value - - public init(_ value: Value) { - self.value = value - } - - public init(wrappedValue: Value) { - self.value = wrappedValue - } - - public var wrappedValue: Value { - _read { yield self.value } - _modify { yield &self.value } - } - - public var projectedValue: Self { - get { self } - set { self = newValue } - } - - public subscript(dynamicMember keyPath: KeyPath) -> Subject { - self.value[keyPath: keyPath] - } - - public subscript(dynamicMember keyPath: WritableKeyPath) -> Subject { - _read { yield self.value[keyPath: keyPath] } - _modify { yield &self.value[keyPath: keyPath] } - } -} - -extension UncheckedSendable: Equatable where Value: Equatable {} -extension UncheckedSendable: Hashable where Value: Hashable {} - -extension UncheckedSendable: Decodable where Value: Decodable { - public init(from decoder: Decoder) throws { - do { - let container = try decoder.singleValueContainer() - self.init(wrappedValue: try container.decode(Value.self)) - } catch { - self.init(wrappedValue: try Value(from: decoder)) - } - } -} - -extension UncheckedSendable: 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) - } - } -} diff --git a/Sources/ComposableArchitecture/Effects/Publisher.swift b/Sources/ComposableArchitecture/Effects/Publisher.swift index 61288dfabefb..a0f29018a7fa 100644 --- a/Sources/ComposableArchitecture/Effects/Publisher.swift +++ b/Sources/ComposableArchitecture/Effects/Publisher.swift @@ -153,12 +153,13 @@ extension EffectPublisher { public static func future( _ attemptToFulfill: @escaping (@escaping (Result) -> Void) -> Void ) -> Self { - let dependencies = DependencyValues._current - return Deferred { - DependencyValues.$_current.withValue(dependencies) { - Future(attemptToFulfill) - } - }.eraseToEffect() + withEscapedDependencies { escaped in + Deferred { + escaped.yield { + Future(attemptToFulfill) + } + }.eraseToEffect() + } } /// Initializes an effect that lazily executes some work in the real world and synchronously sends @@ -243,13 +244,14 @@ extension EffectPublisher { public static func run( _ work: @escaping (EffectPublisher.Subscriber) -> Cancellable ) -> Self { - let dependencies = DependencyValues._current - return AnyPublisher.create { subscriber in - DependencyValues.$_current.withValue(dependencies) { - work(subscriber) + withEscapedDependencies { escaped in + AnyPublisher.create { subscriber in + escaped.yield { + work(subscriber) + } } + .eraseToEffect() } - .eraseToEffect() } /// Creates an effect that executes some work in the real world that doesn't need to feed data @@ -267,16 +269,17 @@ extension EffectPublisher { // due to a bug in iOS 13.2 that publisher will never complete. The bug was fixed in // iOS 13.3, but to remain compatible with iOS 13.2 and higher we need to do a little // trickery to make sure the deferred publisher completes. - let dependencies = DependencyValues._current - return Deferred { () -> Publishers.CompactMap.Publisher, Action> in - DependencyValues.$_current.withValue(dependencies) { - try? work() + withEscapedDependencies { escaped in + Deferred { () -> Publishers.CompactMap.Publisher, Action> in + escaped.yield { + try? work() + } + return Just(nil) + .setFailureType(to: Failure.self) + .compactMap { $0 } } - return Just(nil) - .setFailureType(to: Failure.self) - .compactMap { $0 } + .eraseToEffect() } - .eraseToEffect() } } @@ -391,7 +394,13 @@ extension Publisher { public func eraseToEffect( _ transform: @escaping (Output) -> T ) -> EffectPublisher { - self.map(transform) + self.map(withEscapedDependencies { escaped in + { action in + escaped.yield { + transform(action) + } + } + }) .eraseToEffect() } @@ -463,15 +472,15 @@ extension Publisher { public func catchToEffect( _ transform: @escaping (Result) -> T ) -> EffectTask { - let dependencies = DependencyValues._current - let transform = { action in - DependencyValues.$_current.withValue(dependencies) { - transform(action) - } - } return self - .map { transform(.success($0)) } + .map(withEscapedDependencies { escaped in + { action in + escaped.yield { + transform(.success(action)) + } + } + }) .catch { Just(transform(.failure($0))) } .eraseToEffect() } diff --git a/Sources/ComposableArchitecture/Internal/SerialExecutor.swift b/Sources/ComposableArchitecture/Internal/SerialExecutor.swift new file mode 100644 index 000000000000..4b2861aede7d --- /dev/null +++ b/Sources/ComposableArchitecture/Internal/SerialExecutor.swift @@ -0,0 +1,12 @@ +import _CAsyncSupport + +@_spi(Internals) public func _withMainSerialExecutor( + @_implicitSelfCapture operation: () async throws -> T +) async rethrows -> T { + let hook = swift_task_enqueueGlobal_hook + defer { swift_task_enqueueGlobal_hook = hook } + swift_task_enqueueGlobal_hook = { job, original in + MainActor.shared.enqueue(unsafeBitCast(job, to: UnownedJob.self)) + } + return try await operation() +} diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift index b75e9d558643..2031e3e2009f 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift @@ -146,7 +146,7 @@ public struct _DependencyKeyWritingReducer: ReducerProtoc public func reduce( into state: inout Base.State, action: Base.Action ) -> EffectTask { - DependencyValues.withValues { + withDependencies { self.update(&$0) } operation: { self.base.reduce(into: &state, action: action) diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index 4e48e0f54add..a6b94f2dfa06 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -542,7 +542,7 @@ public final class TestStore @@ -575,10 +575,13 @@ public final class TestStore=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) - import Clocks - - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) - extension DependencyValues { - public var continuousClock: any Clock { - get { self[ContinuousClockKey.self] } - set { self[ContinuousClockKey.self] = newValue } - } - public var suspendingClock: any Clock { - get { self[SuspendingClockKey.self] } - set { self[SuspendingClockKey.self] = newValue } - } - - private enum ContinuousClockKey: DependencyKey { - static let liveValue: any Clock = ContinuousClock() - static let testValue: any Clock = UnimplementedClock(name: "ContinuousClock") - } - private enum SuspendingClockKey: DependencyKey { - static let liveValue: any Clock = SuspendingClock() - static let testValue: any Clock = UnimplementedClock(name: "SuspendingClock") - } - } -#endif diff --git a/Sources/Dependencies/Dependencies/Context.swift b/Sources/Dependencies/Dependencies/Context.swift deleted file mode 100644 index 4839061021c6..000000000000 --- a/Sources/Dependencies/Dependencies/Context.swift +++ /dev/null @@ -1,25 +0,0 @@ -extension DependencyValues { - /// The current dependency context. - /// - /// The current ``DependencyContext`` can be used to determine how dependencies are loaded by the - /// current runtime. - /// - /// It can also be overridden, for example via ``withValue(_:_:operation:)-705n``, to control how - /// dependencies will be loaded by the runtime for the duration of the override. - /// - /// ```swift - /// DependencyValues.withValue(\.context, .preview) { - /// // Dependencies accessed here default to their "preview" value - /// } - /// ``` - public var context: DependencyContext { - get { self[DependencyContextKey.self] } - set { self[DependencyContextKey.self] = newValue } - } -} - -enum DependencyContextKey: DependencyKey { - static let liveValue = DependencyContext.live - static let previewValue = DependencyContext.preview - static let testValue = DependencyContext.test -} diff --git a/Sources/Dependencies/Dependencies/Date.swift b/Sources/Dependencies/Dependencies/Date.swift deleted file mode 100644 index 022cc3d2e7de..000000000000 --- a/Sources/Dependencies/Dependencies/Date.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation -import XCTestDynamicOverlay - -extension DependencyValues { - /// A dependency that returns the current date. - /// - /// By default, a "live" generator is supplied, which returns the current system date when called - /// by invoking `Date.init` under the hood. When used from a `TestStore`, an "unimplemented" - /// generator that additionally reports test failures is supplied, unless explicitly overridden. - /// - /// You can access the current date from a feature by introducing a ``Dependency`` property - /// wrapper to the generator's ``DateGenerator/now`` property: - /// - /// ```swift - /// final class FeatureModel: ReducerProtocol { - /// @Dependency(\.date.now) var now - /// // ... - /// } - /// ``` - /// - /// To override the current date in tests, you can override the generator using - /// ``withValue(_:_:operation:)-705n``: - /// - /// ```swift - /// DependencyValues.withValue(\.date, .constant(Date(timeIntervalSince1970: 0))) { - /// // Assertions... - /// } - /// ``` - /// - /// Or, if you are using the Composable Architecture, you can override dependencies directly - /// on the `TestStore`: - /// - /// ```swift - /// let store = TestStore( - /// initialState: MyFeature.State() - /// reducer: MyFeature() - /// ) - /// - /// store.dependencies.date = .constant(Date(timeIntervalSince1970: 0)) - /// ``` - public var date: DateGenerator { - get { self[DateGeneratorKey.self] } - set { self[DateGeneratorKey.self] = newValue } - } - - private enum DateGeneratorKey: DependencyKey { - static let liveValue = DateGenerator { Date() } - static let testValue = DateGenerator { - XCTFail(#"Unimplemented: @Dependency(\.date)"#) - return Date() - } - } -} - -/// A dependency that generates a date. -/// -/// See ``DependencyValues/date`` for more information. -public struct DateGenerator: Sendable { - private var generate: @Sendable () -> Date - - /// A generator that returns a constant date. - /// - /// - Parameter now: A date to return. - /// - Returns: A generator that always returns the given date. - public static func constant(_ now: Date) -> Self { - Self { now } - } - - /// The current date. - public var now: Date { - self.generate() - } - - /// Initializes a date generator that generates a date from a closure. - /// - /// - Parameter generate: A closure that returns the current date when called. - public init(_ generate: @escaping @Sendable () -> Date) { - self.generate = generate - } - - public func callAsFunction() -> Date { - self.generate() - } -} diff --git a/Sources/Dependencies/Dependencies/Locale.swift b/Sources/Dependencies/Dependencies/Locale.swift deleted file mode 100644 index b1d9e661ca60..000000000000 --- a/Sources/Dependencies/Dependencies/Locale.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import XCTestDynamicOverlay - -extension DependencyValues { - /// The current locale that features should use. - /// - /// By default, the locale returned from `Locale.autoupdatingCurrent` is supplied. When used - /// from a `TestStore`, access will call to `XCTFail` when invoked, unless explicitly - /// overridden. - /// - /// You can access the current locale from a feature by introducing a ``Dependency`` property - /// wrapper to the property: - /// - /// ```swift - /// final class FeatureModel: ReducerProtocol { - /// @Dependency(\.locale) var locale - /// // ... - /// } - /// ``` - /// - /// To override the current locale in tests, use ``withValue(_:_:operation:)-705n``: - - /// ```swift - /// DependencyValues.withValue(\.locale, Locale(identifier: "en_US")) { - /// // Assertions... - /// } - /// ``` - /// - /// Or, if you are using the Composable Architecture, you can override dependencies directly - /// on the `TestStore`: - /// - /// ```swift - /// let store = TestStore( - /// initialState: MyFeature.State() - /// reducer: MyFeature() - /// ) - /// - /// store.dependencies.locale = Locale(identifier: "en_US") - /// ``` - public var locale: Locale { - get { self[LocaleKey.self] } - set { self[LocaleKey.self] = newValue } - } - - private enum LocaleKey: DependencyKey { - static let liveValue = Locale.autoupdatingCurrent - static var testValue: Locale { - if !DependencyValues.isSetting { - XCTFail(#"Unimplemented: @Dependency(\.locale)"#) - } - return .autoupdatingCurrent - } - } -} diff --git a/Sources/Dependencies/Dependencies/MainQueue.swift b/Sources/Dependencies/Dependencies/MainQueue.swift deleted file mode 100644 index d53dd53f9927..000000000000 --- a/Sources/Dependencies/Dependencies/MainQueue.swift +++ /dev/null @@ -1,97 +0,0 @@ -#if canImport(Combine) - import CombineSchedulers - import Foundation - - extension DependencyValues { - /// The "main" queue. - /// - /// Introduce controllable timing to your features by using the ``Dependency`` property wrapper - /// with a key path to this property. The wrapped value is a Combine scheduler with the time - /// type and options of a dispatch queue. By default, `DispatchQueue.main` will be provided, - /// with the exception of XCTest cases, in which an "unimplemented" scheduler will be provided. - /// - /// For example, you could introduce controllable timing to a Composable Architecture reducer - /// that counts the number of seconds it's onscreen: - /// - /// ``` - /// struct TimerReducer: ReducerProtocol { - /// struct State { - /// var elapsed = 0 - /// } - /// - /// enum Action { - /// case task - /// case timerTicked - /// } - /// - /// @Dependency(\.mainQueue) var mainQueue - /// - /// func reduce(into state: inout State, action: Action) -> EffectTask { - /// switch action { - /// case .task: - /// return .run { send in - /// for await _ in self.mainQueue.timer(interval: .seconds(1)) { - /// await send(.timerTicked) - /// } - /// } - /// - /// case .timerTicked: - /// state.elapsed += 1 - /// return .none - /// } - /// } - /// } - /// ``` - /// - /// And you could test this reducer by overriding its main queue with a test scheduler: - /// - /// ``` - /// let mainQueue = DispatchQueue.test - /// - /// let store = TestStore( - /// initialState: TimerReducer.State() - /// reducer: TimerReducer() - /// .dependency(\.mainQueue, mainQueue.eraseToAnyScheduler()) - /// ) - /// - /// let task = store.send(.task) - /// - /// mainQueue.advance(by: .seconds(1) - /// await store.receive(.timerTicked) { - /// $0.elapsed = 1 - /// } - /// mainQueue.advance(by: .seconds(1) - /// await store.receive(.timerTicked) { - /// $0.elapsed = 2 - /// } - /// await task.cancel() - /// ``` - @available( - iOS, deprecated: 9999.0, message: "Use '\\.continuousClock' or '\\.suspendingClock' instead." - ) - @available( - macOS, deprecated: 9999.0, - message: "Use '\\.continuousClock' or '\\.suspendingClock' instead." - ) - @available( - tvOS, - deprecated: 9999.0, - message: "Use '\\.continuousClock' or '\\.suspendingClock' instead." - ) - @available( - watchOS, - deprecated: 9999.0, - message: "Use '\\.continuousClock' or '\\.suspendingClock' instead." - ) - public var mainQueue: AnySchedulerOf { - get { self[MainQueueKey.self] } - set { self[MainQueueKey.self] = newValue } - } - - private enum MainQueueKey: DependencyKey { - static let liveValue = AnySchedulerOf.main - static let testValue = AnySchedulerOf - .unimplemented(#"@Dependency(\.mainQueue)"#) - } - } -#endif diff --git a/Sources/Dependencies/Dependencies/MainRunLoop.swift b/Sources/Dependencies/Dependencies/MainRunLoop.swift deleted file mode 100644 index d94ba3bd0066..000000000000 --- a/Sources/Dependencies/Dependencies/MainRunLoop.swift +++ /dev/null @@ -1,96 +0,0 @@ -#if canImport(Combine) - import CombineSchedulers - import Foundation - - extension DependencyValues { - /// The "main" run loop. - /// - /// Introduce controllable timing to your features by using the ``Dependency`` property wrapper - /// with a key path to this property. The wrapped value is a Combine scheduler with the time - /// type and options of a run loop. By default, `RunLoop.main` will be provided, with the - /// exception of XCTest cases, in which an "unimplemented" scheduler will be provided. - /// - /// For example, you could introduce controllable timing to a Composable Architecture reducer - /// that counts the number of seconds it's onscreen: - /// - /// ``` - /// struct TimerReducer: ReducerProtocol { - /// struct State { - /// var elapsed = 0 - /// } - /// - /// enum Action { - /// case task - /// case timerTicked - /// } - /// - /// @Dependency(\.mainRunLoop) var mainRunLoop - /// - /// func reduce(into state: inout State, action: Action) -> EffectTask { - /// switch action { - /// case .task: - /// return .run { send in - /// for await _ in self.mainRunLoop.timer(interval: .seconds(1)) { - /// send(.timerTicked) - /// } - /// } - /// - /// case .timerTicked: - /// state.elapsed += 1 - /// return .none - /// } - /// } - /// } - /// ``` - /// - /// And you could test this reducer by overriding its main run loop with a test scheduler: - /// - /// ``` - /// let mainRunLoop = DispatchQueue.test - /// - /// let store = TestStore( - /// initialState: TimerReducer.State() - /// reducer: TimerReducer() - /// .dependency(\.mainRunLoop, mainQueue.eraseToAnyScheduler()) - /// ) - /// - /// let task = store.send(.task) - /// - /// mainRunLoop.advance(by: .seconds(1) - /// await store.receive(.timerTicked) { - /// $0.elapsed = 1 - /// } - /// mainRunLoop.advance(by: .seconds(1) - /// await store.receive(.timerTicked) { - /// $0.elapsed = 2 - /// } - /// await task.cancel() - /// ``` - @available( - iOS, deprecated: 9999.0, message: "Use '\\.continuousClock' or '\\.suspendingClock' instead." - ) - @available( - macOS, deprecated: 9999.0, - message: "Use '\\.continuousClock' or '\\.suspendingClock' instead." - ) - @available( - tvOS, - deprecated: 9999.0, - message: "Use '\\.continuousClock' or '\\.suspendingClock' instead." - ) - @available( - watchOS, - deprecated: 9999.0, - message: "Use '\\.continuousClock' or '\\.suspendingClock' instead." - ) - public var mainRunLoop: AnySchedulerOf { - get { self[MainRunLoopKey.self] } - set { self[MainRunLoopKey.self] = newValue } - } - - private enum MainRunLoopKey: DependencyKey { - static let liveValue = AnySchedulerOf.main - static let testValue = AnySchedulerOf.unimplemented(#"@Dependency(\.mainRunLoop)"#) - } - } -#endif diff --git a/Sources/Dependencies/Dependencies/OpenURL.swift b/Sources/Dependencies/Dependencies/OpenURL.swift deleted file mode 100644 index 7970046c1fd6..000000000000 --- a/Sources/Dependencies/Dependencies/OpenURL.swift +++ /dev/null @@ -1,61 +0,0 @@ -import XCTestDynamicOverlay - -#if canImport(SwiftUI) - import SwiftUI - - extension DependencyValues { - /// A dependency that opens a URL. - @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) - public var openURL: OpenURLEffect { - get { self[OpenURLKey.self] } - set { self[OpenURLKey.self] = newValue } - } - } - - @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) - private enum OpenURLKey: DependencyKey { - static let liveValue = OpenURLEffect { url in - let stream = AsyncStream { continuation in - let task = Task { @MainActor in - #if os(watchOS) - EnvironmentValues().openURL(url) - continuation.yield(true) - continuation.finish() - #else - EnvironmentValues().openURL(url) { canOpen in - continuation.yield(canOpen) - continuation.finish() - } - #endif - } - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - return await stream.first(where: { _ in true }) ?? false - } - static let testValue = OpenURLEffect { _ in - XCTFail(#"Unimplemented: @Dependency(\.openURL)"#) - return false - } - } - - public struct OpenURLEffect: Sendable { - private let handler: @Sendable (URL) async -> Bool - - public init(handler: @escaping @Sendable (URL) async -> Bool) { - self.handler = handler - } - - @available(watchOS, unavailable) - @discardableResult - public func callAsFunction(_ url: URL) async -> Bool { - await self.handler(url) - } - - @_disfavoredOverload - public func callAsFunction(_ url: URL) async { - _ = await self.handler(url) - } - } -#endif diff --git a/Sources/Dependencies/Dependencies/RandomNumberGenerator.swift b/Sources/Dependencies/Dependencies/RandomNumberGenerator.swift deleted file mode 100644 index 9a08e02f0402..000000000000 --- a/Sources/Dependencies/Dependencies/RandomNumberGenerator.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -import XCTestDynamicOverlay - -extension DependencyValues { - /// A dependency that yields a random number generator to a closure. - /// - /// Introduce controllable randomness to your features by using the ``Dependency`` property - /// wrapper with a key path to this property. The wrapped value is an instance of - /// ``WithRandomNumberGenerator``, which can be called with a closure to yield a random number - /// generator. (It can be called directly because it defines - /// ``WithRandomNumberGenerator/callAsFunction(_:)``, which is called when you invoke the instance - /// as you would invoke a function.) - /// - /// For example, you could introduce controllable randomness to a Composable Architecture reducer - /// that models rolling a couple dice: - /// - /// ```swift - /// struct Game: ReducerProtocol { - /// struct State { - /// var dice = (1, 1) - /// } - /// - /// enum Action { - /// case rollDice - /// } - /// - /// @Dependency(\.withRandomNumberGenerator) var withRandomNumberGenerator - /// - /// func reduce(into state: inout State, action: Action) -> EffectTask { - /// switch action { - /// case .rollDice: - /// self.withRandomNumberGenerator { generator in - /// state.dice.0 = Int.random(in: 1...6, using: &generator) - /// state.dice.1 = Int.random(in: 1...6, using: &generator) - /// } - /// return .none - /// } - /// } - /// } - /// ``` - /// - /// By default, a `SystemRandomNumberGenerator` will be provided to the closure, with the - /// exception of a `TestStore`, in which an unimplemented dependency will be provided that calls - /// `XCTFail`. - /// - /// To test a reducer that depends on randomness, you can override its random number generator. - /// Inject a dependency by calling ``WithRandomNumberGenerator/init(_:)`` with a random number - /// generator that offers predictable randomness. For example, you could test the dice-rolling of - /// a game's reducer by supplying a seeded random number generator as a dependency: - /// - /// ```swift - /// let store = TestStore( - /// initialState: Game.State() - /// reducer: Game() - /// ) - /// - /// store.dependencies.withRandomNumberGenerator = WithRandomNumberGenerator( - /// LCRNG(seed: 0) - /// ) - /// - /// await store.send(.rollDice) { - /// $0.dice = (1, 3) - /// } - /// ``` - public var withRandomNumberGenerator: WithRandomNumberGenerator { - get { self[WithRandomNumberGeneratorKey.self] } - set { self[WithRandomNumberGeneratorKey.self] = newValue } - } - - private enum WithRandomNumberGeneratorKey: DependencyKey { - static let liveValue = WithRandomNumberGenerator(SystemRandomNumberGenerator()) - static let testValue = WithRandomNumberGenerator(UnimplementedRandomNumberGenerator()) - } -} - -/// A dependency that yields a random number generator to a closure. -/// -/// See ``DependencyValues/withRandomNumberGenerator`` for more information. -public final class WithRandomNumberGenerator: @unchecked Sendable { - private var generator: RandomNumberGenerator - private let lock = NSLock() - - public init(_ generator: T) { - self.generator = generator - } - - public func callAsFunction(_ work: (inout RandomNumberGenerator) -> R) -> R { - self.lock.lock() - defer { self.lock.unlock() } - return work(&self.generator) - } -} - -private struct UnimplementedRandomNumberGenerator: RandomNumberGenerator { - var generator = SystemRandomNumberGenerator() - - mutating func next() -> UInt64 { - XCTFail(#"Unimplemented: @Dependency(\.withRandomNumberGenerator)"#) - return generator.next() - } -} diff --git a/Sources/Dependencies/Dependencies/TimeZone.swift b/Sources/Dependencies/Dependencies/TimeZone.swift deleted file mode 100644 index 1cbaa4b57ffc..000000000000 --- a/Sources/Dependencies/Dependencies/TimeZone.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation -import XCTestDynamicOverlay - -extension DependencyValues { - /// The current time zone that features should use when handling dates. - /// - /// By default, the time zone returned from `TimeZone.autoupdatingCurrent` is supplied. When - /// used from a `TestStore`, access will call to `XCTFail` when invoked, unless explicitly - /// overridden: - /// - /// ```swift - /// let store = TestStore( - /// initialState: MyFeature.State() - /// reducer: MyFeature() - /// ) - /// - /// store.dependencies.timeZone = TimeZone(secondsFromGMT: 0) - /// ``` - public var timeZone: TimeZone { - get { self[TimeZoneKey.self] } - set { self[TimeZoneKey.self] = newValue } - } - - private enum TimeZoneKey: DependencyKey { - static let liveValue = TimeZone.autoupdatingCurrent - static var testValue: TimeZone { - if !DependencyValues.isSetting { - XCTFail(#"Unimplemented: @Dependency(\.timeZone)"#) - } - return .autoupdatingCurrent - } - } -} diff --git a/Sources/Dependencies/Dependencies/URLSession.swift b/Sources/Dependencies/Dependencies/URLSession.swift deleted file mode 100644 index 950da276ae82..000000000000 --- a/Sources/Dependencies/Dependencies/URLSession.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation -import XCTestDynamicOverlay - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -extension DependencyValues { - /// The URL session that features should use to make URL requests. - /// - /// By default, the session returned from `URLSession.shared` is supplied. When used from a - /// `TestStore`, access will call to `XCTFail` when invoked, unless explicitly overridden: - /// - /// ```swift - /// let store = TestStore( - /// initialState: MyFeature.State() - /// reducer: MyFeature() - /// ) - /// - /// let mockConfiguration = URLSessionConfiguration.ephemeral - /// mockConfiguration.protocolClasses = [MyMockURLProtocol.self] - /// store.dependencies.urlSession = URLSession(configuration: mockConfiguration) - /// ``` - /// - /// ### API client dependencies - /// - /// While it is possible to use this dependency value from more complex dependencies, like API - /// clients, we generally advise against _designing_ a dependency around a URL session. Mocking a - /// URL session's responses is a complex process that requires a lot of work that can be avoided. - /// - /// For example, instead of defining your dependency in a way that holds directly onto a URL - /// session in order to invoke it from a concrete implementation: - /// - /// ```swift - /// struct APIClient { - /// let urlSession: URLSession - /// - /// func fetchProfile() async throws -> Profile { - /// // Use URL session to make request - /// } - /// - /// func fetchTimeline() async throws -> Timeline { /* ... */ } - /// // ... - /// } - /// ``` - /// - /// Define your dependency as a lightweight _interface_ that holds onto endpoints that can be - /// individually overridden in a lightweight fashion: - /// - /// ```swift - /// struct APIClient { - /// var fetchProfile: () async throws -> Profile - /// var fetchTimeline: () async throws -> Timeline - /// // ... - /// } - /// ``` - /// - /// Then, you can extend this type with a live implementation that uses a URL session under the - /// hood: - /// - /// ```swift - /// extension APIClient { - /// static func live(urlSession: URLSession) -> Self { - /// Self( - /// fetchProfile: { - /// // Use URL session to make request - /// } - /// fetchTimeline: { /* ... */ }, - /// // ... - /// ) - /// } - /// } - /// ``` - public var urlSession: URLSession { - get { self[URLSessionKey.self] } - set { self[URLSessionKey.self] = newValue } - } - - private enum URLSessionKey: DependencyKey { - static let liveValue = URLSession.shared - static var testValue: URLSession { - if !DependencyValues.isSetting { - XCTFail(#"Unimplemented: @Dependency(\.urlSession)"#) - } - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [UnimplementedURLProtocol.self] - return URLSession(configuration: configuration) - } - } -} - -private final class UnimplementedURLProtocol: URLProtocol { - override class func canInit(with request: URLRequest) -> Bool { - true - } - - override class func canonicalRequest(for request: URLRequest) -> URLRequest { - request - } - - override func startLoading() { - struct UnimplementedURLSession: Error {} - self.client?.urlProtocol(self, didFailWithError: UnimplementedURLSession()) - } - - override func stopLoading() { - } -} diff --git a/Sources/Dependencies/Dependencies/UUID.swift b/Sources/Dependencies/Dependencies/UUID.swift deleted file mode 100644 index b2907b0c5b01..000000000000 --- a/Sources/Dependencies/Dependencies/UUID.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Foundation -import XCTestDynamicOverlay - -extension DependencyValues { - /// A dependency that generates UUIDs. - /// - /// Introduce controllable UUID generation to your features by using the ``Dependency`` property - /// wrapper with a key path to this property. The wrapped value is an instance of - /// ``UUIDGenerator``, which can be called with a closure to create UUIDs. (It can be called - /// directly because it defines ``UUIDGenerator/callAsFunction()``, which is called when you - /// invoke the instance as you would invoke a function.) - /// - /// For example, you could introduce controllable UUID generation to a reducer that creates to-dos - /// with unique identifiers: - /// - /// ```swift - /// struct Todo: Identifiable { - /// let id: UUID - /// var description: String = "" - /// } - /// - /// struct TodosReducer: ReducerProtocol { - /// struct State { - /// var todos: IdentifiedArrayOf = [] - /// } - /// - /// enum Action { - /// case create - /// } - /// - /// @Dependency(\.uuid) var uuid - /// - /// func reduce(into state: inout State, action: Action) -> EffectTask { - /// switch action { - /// case .create: - /// state.append(Todo(id: self.uuid()) - /// return .none - /// } - /// } - /// } - /// ``` - /// - /// By default, a "live" generator is supplied, which returns a random UUID when called by - /// invoking `UUID.init` under the hood. When used from a `TestStore`, an "unimplemented" - /// generator that additionally reports test failures is supplied, unless explicitly overridden. - /// - /// To test a reducer that depends on UUID generation, you can override its generator using the - /// `Reducer/dependency(_:_:)` modifier to override the underlying ``UUIDGenerator``: - /// - /// * ``UUIDGenerator/incrementing`` for reproducible UUIDs that count up from - /// `00000000-0000-0000-0000-000000000000`. - /// - /// * ``UUIDGenerator/constant(_:)`` for a generator that always returns the given UUID. - /// - /// For example, you could test the to-do-creating reducer by supplying an - /// ``UUIDGenerator/incrementing`` generator as a dependency: - /// - /// ```swift - /// let store = TestStore( - /// initialState: Todos.State() - /// reducer: Todos() - /// ) - /// - /// store.dependencies.uuid = .incrementing - /// - /// store.send(.create) { - /// $0.todos = [ - /// Todo(id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) - /// ] - /// } - /// ``` - public var uuid: UUIDGenerator { - get { self[UUIDGeneratorKey.self] } - set { self[UUIDGeneratorKey.self] = newValue } - } - - private enum UUIDGeneratorKey: DependencyKey { - static let liveValue = UUIDGenerator { UUID() } - static let testValue = UUIDGenerator { - XCTFail(#"Unimplemented: @Dependency(\.uuid)"#) - return UUID() - } - } -} - -/// A dependency that generates a UUID. -/// -/// See ``DependencyValues/uuid`` for more information. -public struct UUIDGenerator: Sendable { - private let generate: @Sendable () -> UUID - - /// A generator that returns a constant UUID. - /// - /// - Parameter uuid: A UUID to return. - /// - Returns: A generator that always returns the given UUID. - public static func constant(_ uuid: UUID) -> Self { - Self { uuid } - } - - /// A generator that generates UUIDs in incrementing order. - /// - /// For example: - /// - /// ```swift - /// let generate = UUIDGenerator.incrementing - /// generate() // UUID(00000000-0000-0000-0000-000000000000) - /// generate() // UUID(00000000-0000-0000-0000-000000000001) - /// generate() // UUID(00000000-0000-0000-0000-000000000002) - /// ``` - public static var incrementing: Self { - let generator = IncrementingUUIDGenerator() - return Self { generator() } - } - - /// Initializes a UUID generator that generates a UUID from a closure. - /// - /// - Parameter generate: A closure that returns the current date when called. - public init(_ generate: @escaping @Sendable () -> UUID) { - self.generate = generate - } - - public func callAsFunction() -> UUID { - self.generate() - } -} - -private final class IncrementingUUIDGenerator: @unchecked Sendable { - private let lock = NSLock() - private var sequence = 0 - - func callAsFunction() -> UUID { - self.lock.lock() - defer { - self.sequence += 1 - self.lock.unlock() - } - return UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012x", self.sequence))")! - } -} diff --git a/Sources/Dependencies/Dependency.swift b/Sources/Dependencies/Dependency.swift deleted file mode 100644 index 50eb8d91676c..000000000000 --- a/Sources/Dependencies/Dependency.swift +++ /dev/null @@ -1,94 +0,0 @@ -/// A property wrapper for accessing dependencies. -/// -/// All dependencies are stored in ``DependencyValues`` and one uses this property wrapper to -/// gain access to a particular dependency. Typically it used to provide dependencies to features -/// such as an observable object: -/// -/// ```swift -/// final class FeatureModel: ObservableObject { -/// @Dependency(\.apiClient) var apiClient -/// @Dependency(\.continuousClock) var clock -/// @Dependency(\.uuid) var uuid -/// -/// // ... -/// } -/// ``` -/// -/// Or, if you are using the Composable Architecture: -/// -/// ```swift -/// struct Feature: ReducerProtocol { -/// @Dependency(\.apiClient) var apiClient -/// @Dependency(\.continuousClock) var clock -/// @Dependency(\.uuid) var uuid -/// -/// // ... -/// } -/// ``` -/// -/// But it can be used in other situations too, such as a shared helper function of constructing -/// an effect that can be used from multiple reducers: -/// -/// ```swift -/// func sharedEffect() async throws -> Action { -/// @Dependency(\.apiClient) var apiClient -/// @Dependency(\.continuousClock) var clock -/// -/// // ... -/// } -/// ``` -/// -/// For the complete list of dependency values provided by the library, see the properties of the -/// ``DependencyValues`` structure. For information about creating custom dependency values, -/// see the ``DependencyKey`` protocol. -@propertyWrapper -public struct Dependency: @unchecked Sendable { - // NB: Key paths do not conform to sendable and are instead diagnosed at the time of forming the - // literal. - private let keyPath: KeyPath - private let file: StaticString - private let fileID: StaticString - private let line: UInt - - /// Creates a dependency property to read the specified key path. - /// - /// Don't call this initializer directly. Instead, declare a property with the `Dependency` - /// property wrapper, and provide the key path of the dependency value that the property should - /// reflect: - /// - /// ```swift - /// final class FeatureModel: ObservableObject { - /// @Dependency(\.date) var date - /// - /// // ... - /// } - /// ``` - /// - /// - Parameter keyPath: A key path to a specific resulting value. - public init( - _ keyPath: KeyPath, - file: StaticString = #file, - fileID: StaticString = #fileID, - line: UInt = #line - ) { - self.keyPath = keyPath - self.file = file - self.fileID = fileID - self.line = line - } - - /// The current value of the dependency property. - public var wrappedValue: Value { - #if DEBUG - var currentDependency = DependencyValues.currentDependency - currentDependency.file = self.file - currentDependency.fileID = self.fileID - currentDependency.line = self.line - return DependencyValues.$currentDependency.withValue(currentDependency) { - DependencyValues._current[keyPath: self.keyPath] - } - #else - return DependencyValues._current[keyPath: self.keyPath] - #endif - } -} diff --git a/Sources/Dependencies/DependencyContext.swift b/Sources/Dependencies/DependencyContext.swift deleted file mode 100644 index bdfc227818ec..000000000000 --- a/Sources/Dependencies/DependencyContext.swift +++ /dev/null @@ -1,32 +0,0 @@ -/// A context for a collection of ``DependencyValues``. -/// -/// There are three distinct contexts that dependencies can be loaded from and registered to: -/// -/// * ``live``: The default context. -/// * ``preview``: A context for Xcode previews. -/// * ``test``: A context for tests. -public enum DependencyContext: Sendable { - /// The default, "live" context for dependencies. - /// - /// This context is the default when a ``preview`` or ``test`` context is not detected. - /// - /// Dependencies accessed from a live context will use ``DependencyKey/liveValue`` to request a - /// default value. - case live - - /// A "preview" context for dependencies. - /// - /// This context is the default when run from an Xcode preview. - /// - /// Dependencies accessed from a preview context will use ``TestDependencyKey/previewValue-8u2sy`` - /// to request a default value. - case preview - - /// A "test" context for dependencies. - /// - /// This context is the default when run from an XCTest run. - /// - /// Dependencies accessed from a test context will use ``TestDependencyKey/testValue`` to request - /// a default value. - case test -} diff --git a/Sources/Dependencies/DependencyKey.swift b/Sources/Dependencies/DependencyKey.swift deleted file mode 100644 index 034463acfb2a..000000000000 --- a/Sources/Dependencies/DependencyKey.swift +++ /dev/null @@ -1,239 +0,0 @@ -import XCTestDynamicOverlay - -/// A key for accessing dependencies. -/// -/// Types conform to this protocol to extend ``DependencyValues`` with custom dependencies. It is -/// similar to SwiftUI's `EnvironmentKey` protocol, which is used to add values to -/// `EnvironmentValues`. -/// -/// `DependencyKey` has one main requirement, ``liveValue``, which must return a default value for -/// your dependency that is used when the application is run in a simulator or device. If the -/// ``liveValue`` is accessed while your feature runs in a `TestStore` a test failure will be -/// triggered. -/// -/// To add a `UserClient` dependency that can fetch and save user values can be done like so: -/// -/// ```swift -/// // The user client dependency. -/// struct UserClient { -/// var fetchUser: (User.ID) async throws -> User -/// var saveUser: (User) async throws -> Void -/// } -/// // Conform to DependencyKey to provide a live implementation of -/// // the interface. -/// extension UserClient: DependencyKey { -/// static let liveValue = Self( -/// fetchUser: { /* Make request to fetch user */ }, -/// saveUser: { /* Make request to save user */ } -/// ) -/// } -/// // Register the dependency within DependencyValues. -/// extension DependencyValues { -/// var userClient: UserClient { -/// get { self[UserClient.self] } -/// set { self[UserClient.self] = newValue } -/// } -/// } -/// ``` -/// -/// When a dependency is first accessed its value is cached so that it will not be requested again. -/// This means if your `liveValue` is implemented as a computed property instead of a `static let`, -/// then it will only be called a single time: -/// -/// ```swift -/// extension UserClient: DependencyKey { -/// static var liveValue: Self { -/// // Only called once when dependency is first accessed. -/// return Self(…) -/// } -/// } -/// ``` -/// -/// `DependencyKey` inherits from ``TestDependencyKey``, which has two other overridable -/// requirements: ``TestDependencyKey/testValue``, which should return a default value for the -/// purpose of testing, and ``TestDependencyKey/previewValue-8u2sy``, which can return a default -/// value suitable for Xcode previews. When left unimplemented, these endpoints will return the -/// ``liveValue``, instead. -/// -/// If you plan on separating your interface from your live implementation, conform to -/// ``TestDependencyKey`` in your interface module, and conform to `DependencyKey` in your -/// implementation module. -public protocol DependencyKey: TestDependencyKey { - /// The live value for the dependency key. - /// - /// This is the value used by default when running the application in a simulator or on a device. - /// Using a live dependency in a test context will lead to a test failure as you should mock - /// your dependencies for tests. - /// - /// To automatically supply a test dependency in a test context, consider implementing the - /// ``testValue-535kh`` requirement. - static var liveValue: Value { get } - - // NB: The associated type and requirements of TestDependencyKey are repeated in this protocol - // due to a Swift compiler bug that prevents it from inferring the associated type in - // in the base protocol. See this issue for more information: - // https://github.com/apple/swift/issues/61077 - - /// The associated type representing the type of the dependency key's value. - associatedtype Value = Self - - /// The preview value for the dependency key. - /// - /// This value is automatically used when the associated dependency value is accessed from an - /// Xcode preview, as well as when the current ``DependencyValues/context`` is set to - /// ``DependencyContext/preview``: - /// - /// ```swift - /// DependencyValues.withValues { - /// $0.context = .preview - /// } operation: { - /// // Dependencies accessed here default to their "preview" value - /// } - /// ``` - static var previewValue: Value { get } - - /// The test value for the dependency key. - /// - /// This value is automatically used when the associated dependency value is accessed from an - /// XCTest run, as well as when the current ``DependencyValues/context`` is set to - /// ``DependencyContext/test``: - /// - /// ```swift - /// DependencyValues.withValues { - /// $0.context = .test - /// } operation: { - /// // Dependencies accessed here default to their "test" value - /// } - /// ``` - static var testValue: Value { get } -} - -/// A key for accessing test dependencies. -/// -/// This protocol lives one layer below ``DependencyKey`` and allows you to separate a dependency's -/// interface from its live implementation. -/// -/// ``TestDependencyKey`` has one main requirement, ``testValue``, which must return a default value -/// for the purposes of testing, and one optional requirement, ``previewValue-8u2sy``, which can -/// return a default value suitable for Xcode previews, or the ``testValue``, if left unimplemented. -/// -/// See ``DependencyKey`` to define a static, default value for the live application. -public protocol TestDependencyKey { - /// The associated type representing the type of the dependency key's value. - associatedtype Value = Self - - // NB: This associated type should be constrained to `Sendable` when this bug is fixed: - // https://github.com/apple/swift/issues/60649 - - /// The preview value for the dependency key. - /// - /// This value is automatically used when the associated dependency value is accessed from an - /// Xcode preview, as well as when the current ``DependencyValues/context`` is set to - /// ``DependencyContext/preview``: - /// - /// ```swift - /// DependencyValues.withValues { - /// $0.context = .preview - /// } operation: { - /// // Dependencies accessed here default to their "preview" value - /// } - /// ``` - static var previewValue: Value { get } - - /// The test value for the dependency key. - /// - /// This value is automatically used when the associated dependency value is accessed from an - /// XCTest run, as well as when the current ``DependencyValues/context`` is set to - /// ``DependencyContext/test``: - /// - /// ```swift - /// DependencyValues.withValues { - /// $0.context = .test - /// } operation: { - /// // Dependencies accessed here default to their "test" value - /// } - /// ``` - static var testValue: Value { get } -} - -extension DependencyKey { - /// A default implementation that provides the ``liveValue`` to Xcode previews. - /// - /// You may provide your own default `previewValue` in your conformance to ``TestDependencyKey``, - /// which will take precedence over this implementation. - public static var previewValue: Value { Self.liveValue } - - /// A default implementation that provides the ``liveValue`` to XCTest runs, but will trigger test - /// failure to occur if accessed. - /// - /// To prevent test failures, explicitly override the dependency in any tests in which it is - /// accessed: - /// - /// ```swift - /// func testFeatureThatUsesMyDependency() { - /// DependencyValues.withValues { - /// $0.myDependency = .mock // Override dependency - /// } operation: { - /// // Test feature with dependency overridden - /// } - /// } - /// ``` - /// - /// You may provide your own default `testValue` in your conformance to ``TestDependencyKey``, - /// which will take precedence over this implementation. - public static var testValue: Value { - var dependencyDescription = "" - if let fileID = DependencyValues.currentDependency.fileID, - let line = DependencyValues.currentDependency.line - { - dependencyDescription.append( - """ - Location: - \(fileID):\(line) - - """ - ) - } - dependencyDescription.append( - Self.self == Value.self - ? """ - Dependency: - \(typeName(Value.self)) - """ - : """ - Key: - \(typeName(Self.self)) - Value: - \(typeName(Value.self)) - """ - ) - // TODO: Make this error message configurable to avoid TCA-specific language outside of TCA? - XCTFail( - """ - \(DependencyValues.currentDependency.name.map { "@Dependency(\\.\($0))" } ?? "A dependency") \ - has no test implementation, but was accessed from a test context: - - \(dependencyDescription) - - Dependencies registered with the library are not allowed to use their default, live \ - implementations when run from tests. - - To fix, override \ - \(DependencyValues.currentDependency.name.map { "'\($0)'" } ?? "the dependency") with a mock \ - value in your test. If you are using the Composable Architecture, mutate the 'dependencies' \ - property on your 'TestStore'. Otherwise, use 'DependencyValues.withValues' to define a scope \ - for the override. If you'd like to provide a default value for all tests, implement the \ - 'testValue' requirement of the 'DependencyKey' protocol. - """ - ) - return Self.liveValue - } -} - -extension TestDependencyKey { - /// A default implementation that provides the ``testValue`` to Xcode previews. - /// - /// You may provide your own default `previewValue` in your conformance to ``TestDependencyKey``, - /// which will take precedence over this implementation. - public static var previewValue: Value { Self.testValue } -} diff --git a/Sources/Dependencies/DependencyValues.swift b/Sources/Dependencies/DependencyValues.swift deleted file mode 100644 index 4c7d48899338..000000000000 --- a/Sources/Dependencies/DependencyValues.swift +++ /dev/null @@ -1,394 +0,0 @@ -import Foundation -import XCTestDynamicOverlay - -/// A collection of dependencies that is globally available. -/// -/// To access a particular dependency from the collection you use the ``Dependency`` property -/// wrapper: -/// -/// ```swift -/// @Dependency(\.date) var date -/// let now = date.now -/// ``` -/// -/// To change a dependency for a well-defined scope you can use the -/// ``withValues(_:operation:)-1oaja`` method: -/// -/// ```swift -/// @Dependency(\.date) var date -/// let now = date.now -/// -/// DependencyValues.withValues { -/// $0.date = .constant(Date(timeIntervalSinceReferenceDate: 1234567890)) -/// } operation: { -/// @Dependency(\.date) var date -/// let now = date.now.timeIntervalSinceReferenceDate // 1234567890 -/// } -/// ``` -/// -/// The dependencies will be changed only for the lifetime of the `operation` scope, which can be -/// synchronous or asynchronous. -/// -/// To register a dependency with this storage, you first conform a type to ``DependencyKey``: -/// -/// ```swift -/// private enum MyValueKey: DependencyKey { -/// static let liveValue = 42 -/// } -/// ``` -/// -/// And then extend ``DependencyValues`` with a computed property that uses the key to read and -/// write to ``DependencyValues``: -/// -/// ```swift -/// extension DependencyValues { -/// var myValue: Int { -/// get { self[MyValueKey.self] } -/// set { self[MyValueKey.self] = newValue } -/// } -/// } -/// ``` -/// -/// With those steps done you can access the dependency using the ``Dependency`` property wrapper: -/// -/// ```swift -/// @Dependency(\.myValue) var myValue -/// myValue // 42 -/// ``` -public struct DependencyValues: Sendable { - @TaskLocal public static var _current = Self() - @TaskLocal static var isSetting = false - @TaskLocal static var currentDependency = CurrentDependency() - - private var cachedValues = CachedValues() - private var storage: [ObjectIdentifier: AnySendable] = [:] - - /// Creates a dependency values instance. - /// - /// You don't typically create an instance of ``DependencyValues`` directly. Doing so would - /// provide access only to default values. Instead, you rely on the dependency values' instance - /// that the library manages for you when you use the ``Dependency`` property wrapper. - public init() {} - - /// Updates a single dependency for the duration of a synchronous operation. - /// - /// For example, if you wanted to force the ``DependencyValues/date`` dependency to be a - /// particular date, you can do: - /// - /// ```swift - /// DependencyValues.withValue(\.date, .constant(Date(timeIntervalSince1970: 1234567890))) { - /// // References to date in here are pinned to 1234567890. - /// } - /// ``` - /// - /// See ``withValues(_:operation:)-9prz8`` to update multiple dependencies at once without nesting - /// calls to `withValue`. - /// - /// - Parameters: - /// - keyPath: A key path that indicates the property of the `DependencyValues` structure to - /// update. - /// - value: The new value to set for the item specified by `keyPath`. - /// - operation: The operation to run with the updated dependencies. - /// - Returns: The result returned from `operation`. - public static func withValue( - _ keyPath: WritableKeyPath, - _ value: Value, - operation: () throws -> R - ) rethrows -> R { - try Self.$isSetting.withValue(true) { - var dependencies = Self._current - dependencies[keyPath: keyPath] = value - return try Self.$_current.withValue(dependencies) { - try Self.$isSetting.withValue(false) { - try operation() - } - } - } - } - - /// Updates a single dependency for the duration of an asynchronous operation. - /// - /// For example, if you wanted to force the ``DependencyValues/date`` dependency to be a - /// particular date, you can do: - /// - /// ```swift - /// await DependencyValues.withValue(\.date, .constant(Date(timeIntervalSince1970: 1234567890))) { - /// // References to date in here are pinned to 1234567890. - /// } - /// ``` - /// - /// See ``withValues(_:operation:)-1oaja`` to update multiple dependencies at once without nesting - /// calls to `withValue`. - /// - /// - Parameters: - /// - keyPath: A key path that indicates the property of the `DependencyValues` structure to - /// update. - /// - value: The new value to set for the item specified by `keyPath`. - /// - operation: The operation to run with the updated dependencies. - /// - Returns: The result returned from `operation`. - public static func withValue( - _ keyPath: WritableKeyPath, - _ value: Value, - operation: () async throws -> R - ) async rethrows -> R { - try await Self.$isSetting.withValue(true) { - var dependencies = Self._current - dependencies[keyPath: keyPath] = value - return try await Self.$_current.withValue(dependencies) { - try await Self.$isSetting.withValue(false) { - try await operation() - } - } - } - } - - /// Updates the dependencies for the duration of a synchronous operation. - /// - /// Any mutations made to ``DependencyValues`` inside `updateValuesForOperation` will be visible - /// to everything executed in the operation. For example, if you wanted to force the ``date`` - /// dependency to be a particular date, you can do: - /// - /// ```swift - /// DependencyValues.withValues { - /// $0.date = .constant(Date(timeIntervalSince1970: 1234567890)) - /// } operation: { - /// // References to date in here are pinned to 1234567890. - /// } - /// ``` - /// - /// See ``withValue(_:_:operation:)-3yj9d`` to update a single dependency with a constant value. - /// - /// - Parameters: - /// - updateValuesForOperation: A closure for updating the current dependency values for the - /// duration of the operation. - /// - operation: An operation to perform wherein dependencies have been overridden. - /// - Returns: The result returned from `operation`. - public static func withValues( - _ updateValuesForOperation: (inout Self) throws -> Void, - operation: () throws -> R - ) rethrows -> R { - try Self.$isSetting.withValue(true) { - var dependencies = Self._current - try updateValuesForOperation(&dependencies) - return try Self.$_current.withValue(dependencies) { - try Self.$isSetting.withValue(false) { - try operation() - } - } - } - } - - /// Updates the dependencies for the duration of an asynchronous operation. - /// - /// Any mutations made to ``DependencyValues`` inside `updateValuesForOperation` will be visible - /// to everything executed in the operation. For example, if you wanted to force the - /// ``DependencyValues/date`` dependency to be a particular date, you can do: - /// - /// ```swift - /// await DependencyValues.withValues { - /// $0.date = .constant(Date(timeIntervalSince1970: 1234567890)) - /// } operation: { - /// // References to date in here are pinned to 1234567890. - /// } - /// ``` - /// - /// See ``withValue(_:_:operation:)-705n`` to update a single dependency with a constant value. - /// - /// - Parameters: - /// - updateValuesForOperation: A closure for updating the current dependency values for the - /// duration of the operation. - /// - operation: An operation to perform wherein dependencies have been overridden. - /// - Returns: The result returned from `operation`. - public static func withValues( - _ updateValuesForOperation: (inout Self) async throws -> Void, - operation: () async throws -> R - ) async rethrows -> R { - try await Self.$isSetting.withValue(true) { - var dependencies = Self._current - try await updateValuesForOperation(&dependencies) - return try await Self.$_current.withValue(dependencies) { - try await Self.$isSetting.withValue(false) { - try await operation() - } - } - } - } - - /// Accesses the dependency value associated with a custom key. - /// - /// Create custom dependency values by defining a key that conforms to the ``DependencyKey`` - /// protocol, and then using that key with the subscript operator of the ``DependencyValues`` - /// structure to get and set a value for that key: - /// - /// ```swift - /// private struct MyDependencyKey: DependencyKey { - /// static let testValue = "Default value" - /// } - /// - /// extension DependencyValues { - /// var myCustomValue: String { - /// get { self[MyDependencyKey.self] } - /// set { self[MyDependencyKey.self] = newValue } - /// } - /// } - /// ``` - /// - /// You use custom dependency values the same way you use system-provided values, setting a value - /// with ``withValue(_:_:operation:)-705n``, and reading values with the ``Dependency`` property - /// wrapper. - public subscript( - key: Key.Type, - file: StaticString = #file, - function: StaticString = #function, - line: UInt = #line - ) -> Key.Value where Key.Value: Sendable { - get { - guard let dependency = self.storage[ObjectIdentifier(key)]?.base as? Key.Value - else { - let context = - self.storage[ObjectIdentifier(DependencyContextKey.self)]?.base as? DependencyContext - ?? defaultContext - - switch context { - case .live, .preview: - return self.cachedValues.value( - for: Key.self, - context: context, - file: file, - function: function, - line: line - ) - case .test: - var currentDependency = Self.currentDependency - currentDependency.name = function - return Self.$currentDependency.withValue(currentDependency) { - self.cachedValues.value( - for: Key.self, - context: context, - file: file, - function: function, - line: line - ) - } - } - } - return dependency - } - set { - self.storage[ObjectIdentifier(key)] = AnySendable(newValue) - } - } -} - -private struct AnySendable: @unchecked Sendable { - let base: Any - @inlinable - init(_ base: Base) { - self.base = base - } -} - -struct CurrentDependency { - var name: StaticString? - var file: StaticString? - var fileID: StaticString? - var line: UInt? -} - -private let defaultContext: DependencyContext = { - if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { - return .preview - } else { - return .live - } -}() - -private final class CachedValues: @unchecked Sendable { - struct CacheKey: Hashable, Sendable { - let id: ObjectIdentifier - let context: DependencyContext - } - - private let lock = NSRecursiveLock() - private var cached = [CacheKey: AnySendable]() - - func value( - for key: Key.Type, - context: DependencyContext, - file: StaticString = #file, - function: StaticString = #function, - line: UInt = #line - ) -> Key.Value { - self.lock.lock() - defer { self.lock.unlock() } - - let cacheKey = CacheKey(id: ObjectIdentifier(key), context: context) - guard let value = self.cached[cacheKey]?.base as? Key.Value - else { - let value: Key.Value? - switch context { - case .live: - value = _liveValue(key) as? Key.Value - case .preview: - value = Key.previewValue - case .test: - value = Key.testValue - } - - guard let value = value - else { - if !DependencyValues.isSetting { - var dependencyDescription = "" - if let fileID = DependencyValues.currentDependency.fileID, - let line = DependencyValues.currentDependency.line - { - dependencyDescription.append( - """ - Location: - \(fileID):\(line) - - """ - ) - } - dependencyDescription.append( - Key.self == Key.Value.self - ? """ - Dependency: - \(typeName(Key.Value.self)) - """ - : """ - Key: - \(typeName(Key.self)) - Value: - \(typeName(Key.Value.self)) - """ - ) - - runtimeWarn( - """ - "@Dependency(\\.\(function))" has no live implementation, but was accessed from a \ - live context. - - \(dependencyDescription) - - Every dependency registered with the library must conform to "DependencyKey", and \ - that conformance must be visible to the running application. - - To fix, make sure that "\(typeName(Key.self))" conforms to "DependencyKey" by \ - providing a live implementation of your dependency, and make sure that the \ - conformance is linked with this current application. - """, - file: DependencyValues.currentDependency.file ?? file, - line: DependencyValues.currentDependency.line ?? line - ) - } - return Key.testValue - } - - self.cached[cacheKey] = AnySendable(value) - return value - } - - return value - } -} diff --git a/Sources/Dependencies/Documentation.docc/Dependencies.md b/Sources/Dependencies/Documentation.docc/Dependencies.md deleted file mode 100644 index 5b751f60d233..000000000000 --- a/Sources/Dependencies/Documentation.docc/Dependencies.md +++ /dev/null @@ -1,12 +0,0 @@ -# ``Dependencies`` - -A dependency management library employed by the Composable Architecture, and inspired by SwiftUI's -"environment." - -## Topics - -### Dependency management - -- ``Dependency`` -- ``DependencyValues`` -- ``DependencyKey`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/Dependency.md b/Sources/Dependencies/Documentation.docc/Extensions/Dependency.md deleted file mode 100644 index b43429bd04a0..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/Dependency.md +++ /dev/null @@ -1,11 +0,0 @@ -# ``Dependencies/Dependency`` - -## Topics - -### Using a dependency - -- ``init(_:file:fileID:line:)`` - -### Getting the value - -- ``wrappedValue`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyKey.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyKey.md deleted file mode 100644 index a3be0730efba..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/DependencyKey.md +++ /dev/null @@ -1,14 +0,0 @@ -# ``Dependencies/DependencyKey`` - -## Topics - -### Registering a dependency - -- ``Value`` -- ``liveValue`` -- ``testValue-535kh`` -- ``previewValue-36s5j`` - -### Modularizing a dependency - -- ``TestDependencyKey`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValues.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValues.md deleted file mode 100644 index 4f1a3ddac45c..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValues.md +++ /dev/null @@ -1,27 +0,0 @@ -# ``Dependencies/DependencyValues`` - -## Topics - -### Creating and accessing values - -- ``init()`` -- ``subscript(_:_:_:_:)`` - -### Overriding values - -- ``withValue(_:_:operation:)-3yj9d`` -- ``withValues(_:operation:)-1oaja`` - -### Dependency values - -- ``calendar`` -- ``context`` -- ``date`` -- ``locale`` -- ``mainQueue`` -- ``mainRunLoop`` -- ``openURL`` -- ``timeZone`` -- ``urlSession`` -- ``uuid`` -- ``withRandomNumberGenerator`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesContext.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesContext.md deleted file mode 100644 index 22fb49831d97..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesContext.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``Dependencies/DependencyValues/context`` - -## Topics - -### Dependency context - -- ``DependencyContext`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesDate.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesDate.md deleted file mode 100644 index d92e0911d400..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesDate.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``Dependencies/DependencyValues/date`` - -## Topics - -### Dependency value - -- ``DateGenerator`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesOpenURL.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesOpenURL.md deleted file mode 100644 index 9d6bfe522b11..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesOpenURL.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``Dependencies/DependencyValues/openURL`` - -## Topics - -### Dependency value - -- ``OpenURLEffect`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesUUID.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesUUID.md deleted file mode 100644 index cae008e6866c..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesUUID.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``Dependencies/DependencyValues/uuid`` - -## Topics - -### Dependency value - -- ``UUIDGenerator`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithRandomNumberGenerator.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithRandomNumberGenerator.md deleted file mode 100644 index 65b398516a40..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithRandomNumberGenerator.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``Dependencies/DependencyValues/withRandomNumberGenerator`` - -## Topics - -### Dependency value - -- ``WithRandomNumberGenerator`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithValue.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithValue.md deleted file mode 100644 index 10b27d42d219..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithValue.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``Dependencies/DependencyValues/withValue(_:_:operation:)-705n`` - -## Topics - -### Overloads - -- ``DependencyValues/withValue(_:_:operation:)-3yj9d`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithValues.md b/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithValues.md deleted file mode 100644 index 78bdddf29cb2..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/DependencyValuesWithValues.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``Dependencies/DependencyValues/withValues(_:operation:)-1oaja`` - -## Topics - -### Overloads - -- ``DependencyValues/withValues(_:operation:)-9prz8`` diff --git a/Sources/Dependencies/Documentation.docc/Extensions/TestDependencyKey.md b/Sources/Dependencies/Documentation.docc/Extensions/TestDependencyKey.md deleted file mode 100644 index 689570e23d8c..000000000000 --- a/Sources/Dependencies/Documentation.docc/Extensions/TestDependencyKey.md +++ /dev/null @@ -1,9 +0,0 @@ -# ``Dependencies/TestDependencyKey`` - -## Topics - -### Registering a dependency - -- ``Value`` -- ``testValue`` -- ``previewValue-8u2sy`` diff --git a/Sources/Dependencies/Internal/OpenExistential.swift b/Sources/Dependencies/Internal/OpenExistential.swift deleted file mode 100644 index fa80899effed..000000000000 --- a/Sources/Dependencies/Internal/OpenExistential.swift +++ /dev/null @@ -1,31 +0,0 @@ -#if swift(>=5.7) - // MARK: swift(>=5.7) - - // MARK: DependencyKey - - func _liveValue(_ key: Any.Type) -> Any? { - (key as? any DependencyKey.Type)?.liveValue - } -#else - // MARK: - - // MARK: swift(<5.7) - - private enum Witness {} - - // MARK: DependencyKey - - func _liveValue(_ key: Any.Type) -> Any? { - func open(_: T.Type) -> Any? { - (Witness.self as? AnyDependencyKey.Type)?.liveValue - } - return _openExistential(key, do: open) - } - - protocol AnyDependencyKey { - static var liveValue: Any { get } - } - - extension Witness: AnyDependencyKey where T: DependencyKey { - static var liveValue: Any { T.liveValue } - } -#endif diff --git a/Sources/Dependencies/Internal/RuntimeWarnings.swift b/Sources/Dependencies/Internal/RuntimeWarnings.swift deleted file mode 100644 index 4db54c87955a..000000000000 --- a/Sources/Dependencies/Internal/RuntimeWarnings.swift +++ /dev/null @@ -1,71 +0,0 @@ -@_transparent -@usableFromInline -@inline(__always) -func runtimeWarn( - _ message: @autoclosure () -> String, - category: String? = "Dependencies", - file: StaticString? = nil, - line: UInt? = nil -) { - #if DEBUG - let message = message() - let category = category ?? "Runtime Warning" - if _XCTIsTesting { - if let file = file, let line = line { - XCTFail(message, file: file, line: line) - } else { - XCTFail(message) - } - } else { - #if canImport(os) - os_log( - .fault, - dso: dso, - log: OSLog(subsystem: "com.apple.runtime-issues", category: category), - "%@", - message - ) - #else - fputs("\(formatter.string(from: Date())) [\(category)] \(message)\n", stderr) - #endif - } - #endif -} - -#if DEBUG - import XCTestDynamicOverlay - - #if canImport(os) - import os - - // NB: Xcode runtime warnings offer a much better experience than traditional assertions and - // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. - // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. - // - // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc - @usableFromInline - let dso = { () -> UnsafeMutableRawPointer in - let count = _dyld_image_count() - for i in 0.. String { - var name = _typeName(type, qualified: true) - if let index = name.firstIndex(of: ".") { - name.removeSubrange(...index) - } - let sanitizedName = - name - .replacingOccurrences( - of: #"\(unknown context at \$[[:xdigit:]]+\)\."#, - with: "", - options: .regularExpression - ) - return sanitizedName -} diff --git a/Sources/_CAsyncSupport/_CAsyncSupport.h b/Sources/_CAsyncSupport/_CAsyncSupport.h new file mode 100644 index 000000000000..9780327c338f --- /dev/null +++ b/Sources/_CAsyncSupport/_CAsyncSupport.h @@ -0,0 +1,248 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Async Algorithms open source project +// +// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#include + +#if !defined(__has_feature) +#define __has_feature(x) 0 +#endif + +#if !defined(__has_attribute) +#define __has_attribute(x) 0 +#endif + +#if !defined(__has_builtin) +#define __has_builtin(builtin) 0 +#endif + +#if !defined(__has_cpp_attribute) +#define __has_cpp_attribute(attribute) 0 +#endif + +// TODO: These macro definitions are duplicated in BridgedSwiftObject.h. Move +// them to a single file if we find a location that both Visibility.h and +// BridgedSwiftObject.h can import. +#if __has_feature(nullability) +// Provide macros to temporarily suppress warning about the use of +// _Nullable and _Nonnull. +# define SWIFT_BEGIN_NULLABILITY_ANNOTATIONS \ + _Pragma("clang diagnostic push") \ + _Pragma("clang diagnostic ignored \"-Wnullability-extension\"") +# define SWIFT_END_NULLABILITY_ANNOTATIONS \ + _Pragma("clang diagnostic pop") + +#else +// #define _Nullable and _Nonnull to nothing if we're not being built +// with a compiler that supports them. +# define _Nullable +# define _Nonnull +# define SWIFT_BEGIN_NULLABILITY_ANNOTATIONS +# define SWIFT_END_NULLABILITY_ANNOTATIONS +#endif + +#define SWIFT_MACRO_CONCAT(A, B) A ## B +#define SWIFT_MACRO_IF_0(IF_TRUE, IF_FALSE) IF_FALSE +#define SWIFT_MACRO_IF_1(IF_TRUE, IF_FALSE) IF_TRUE +#define SWIFT_MACRO_IF(COND, IF_TRUE, IF_FALSE) \ + SWIFT_MACRO_CONCAT(SWIFT_MACRO_IF_, COND)(IF_TRUE, IF_FALSE) + +#if __has_attribute(pure) +#define SWIFT_READONLY __attribute__((__pure__)) +#else +#define SWIFT_READONLY +#endif + +#if __has_attribute(const) +#define SWIFT_READNONE __attribute__((__const__)) +#else +#define SWIFT_READNONE +#endif + +#if __has_attribute(always_inline) +#define SWIFT_ALWAYS_INLINE __attribute__((always_inline)) +#else +#define SWIFT_ALWAYS_INLINE +#endif + +#if __has_attribute(noinline) +#define SWIFT_NOINLINE __attribute__((__noinline__)) +#else +#define SWIFT_NOINLINE +#endif + +#if __has_attribute(noreturn) +#define SWIFT_NORETURN __attribute__((__noreturn__)) +#else +#define SWIFT_NORETURN +#endif + +#if __has_attribute(used) +#define SWIFT_USED __attribute__((__used__)) +#else +#define SWIFT_USED +#endif + +#if __has_attribute(unavailable) +#define SWIFT_ATTRIBUTE_UNAVAILABLE __attribute__((__unavailable__)) +#else +#define SWIFT_ATTRIBUTE_UNAVAILABLE +#endif + +#if (__has_attribute(weak_import)) +#define SWIFT_WEAK_IMPORT __attribute__((weak_import)) +#else +#define SWIFT_WEAK_IMPORT +#endif + +// Define the appropriate attributes for sharing symbols across +// image (executable / shared-library) boundaries. +// +// SWIFT_ATTRIBUTE_FOR_EXPORTS will be placed on declarations that +// are known to be exported from the current image. Typically, they +// are placed on header declarations and then inherited by the actual +// definitions. +// +// SWIFT_ATTRIBUTE_FOR_IMPORTS will be placed on declarations that +// are known to be exported from a different image. This never +// includes a definition. +// +// Getting the right attribute on a declaratioon can be pretty awkward, +// but it's necessary under the C translation model. All of this +// ceremony is familiar to Windows programmers; C/C++ programmers +// everywhere else usually don't bother, but since we have to get it +// right for Windows, we have everything set up to get it right on +// other targets as well, and doing so lets the compiler use more +// efficient symbol access patterns. +#if defined(__MACH__) || defined(__wasi__) + +// On Mach-O and WebAssembly, we use non-hidden visibility. We just use +// default visibility on both imports and exports, both because these +// targets don't support protected visibility but because they don't +// need it: symbols are not interposable outside the current image +// by default. +# define SWIFT_ATTRIBUTE_FOR_EXPORTS __attribute__((__visibility__("default"))) +# define SWIFT_ATTRIBUTE_FOR_IMPORTS __attribute__((__visibility__("default"))) + +#elif defined(__ELF__) + +// On ELF, we use non-hidden visibility. For exports, we must use +// protected visibility to tell the compiler and linker that the symbols +// can't be interposed outside the current image. For imports, we must +// use default visibility because protected visibility guarantees that +// the symbol is defined in the current library, which isn't true for +// an import. +// +// The compiler does assume that the runtime and standard library can +// refer to each other's symbols as DSO-local, so it's important that +// we get this right or we can get linker errors. +# define SWIFT_ATTRIBUTE_FOR_EXPORTS __attribute__((__visibility__("protected"))) +# define SWIFT_ATTRIBUTE_FOR_IMPORTS __attribute__((__visibility__("default"))) + +#elif defined(__CYGWIN__) + +// For now, we ignore all this on Cygwin. +# define SWIFT_ATTRIBUTE_FOR_EXPORTS +# define SWIFT_ATTRIBUTE_FOR_IMPORTS + +// FIXME: this #else should be some sort of #elif Windows +#else // !__MACH__ && !__ELF__ + +// On PE/COFF, we use dllimport and dllexport. +# define SWIFT_ATTRIBUTE_FOR_EXPORTS __declspec(dllexport) +# define SWIFT_ATTRIBUTE_FOR_IMPORTS __declspec(dllimport) + +#endif + +// CMake conventionally passes -DlibraryName_EXPORTS when building +// code that goes into libraryName. This isn't the best macro name, +// but it's conventional. We do have to pass it explicitly in a few +// places in the build system for a variety of reasons. +// +// Unfortunately, defined(D) is a special function you can use in +// preprocessor conditions, not a macro you can use anywhere, so we +// need to manually check for all the libraries we know about so that +// we can use them in our condition below.s +#if defined(swiftCore_EXPORTS) +#define SWIFT_IMAGE_EXPORTS_swiftCore 1 +#else +#define SWIFT_IMAGE_EXPORTS_swiftCore 0 +#endif +#if defined(swift_Concurrency_EXPORTS) +#define SWIFT_IMAGE_EXPORTS_swift_Concurrency 1 +#else +#define SWIFT_IMAGE_EXPORTS_swift_Concurrency 0 +#endif +#if defined(swift_Distributed_EXPORTS) +#define SWIFT_IMAGE_EXPORTS_swift_Distributed 1 +#else +#define SWIFT_IMAGE_EXPORTS_swift_Distributed 0 +#endif +#if defined(swift_Differentiation_EXPORTS) +#define SWIFT_IMAGE_EXPORTS_swift_Differentiation 1 +#else +#define SWIFT_IMAGE_EXPORTS_swift_Differentiation 0 +#endif + +#define SWIFT_EXPORT_FROM_ATTRIBUTE(LIBRARY) \ + SWIFT_MACRO_IF(SWIFT_IMAGE_EXPORTS_##LIBRARY, \ + SWIFT_ATTRIBUTE_FOR_EXPORTS, \ + SWIFT_ATTRIBUTE_FOR_IMPORTS) + +// SWIFT_EXPORT_FROM(LIBRARY) declares something to be a C-linkage +// entity exported by the given library. +// +// SWIFT_RUNTIME_EXPORT is just SWIFT_EXPORT_FROM(swiftCore). +// +// TODO: use this in shims headers in overlays. +#if defined(__cplusplus) +#define SWIFT_EXPORT_FROM(LIBRARY) extern "C" SWIFT_EXPORT_FROM_ATTRIBUTE(LIBRARY) +#define SWIFT_EXPORT extern "C" +#else +#define SWIFT_EXPORT extern +#define SWIFT_EXPORT_FROM(LIBRARY) SWIFT_EXPORT_FROM_ATTRIBUTE(LIBRARY) +#endif +#define SWIFT_RUNTIME_EXPORT SWIFT_EXPORT_FROM(swiftCore) + +// Define mappings for calling conventions. + +// Annotation for specifying a calling convention of +// a runtime function. It should be used with declarations +// of runtime functions like this: +// void runtime_function_name() SWIFT_CC(swift) +#define SWIFT_CC(CC) SWIFT_CC_##CC + +// SWIFT_CC(c) is the C calling convention. +#define SWIFT_CC_c + +// SWIFT_CC(swift) is the Swift calling convention. +// FIXME: the next comment is false. +// Functions outside the stdlib or runtime that include this file may be built +// with a compiler that doesn't support swiftcall; don't define these macros +// in that case so any incorrect usage is caught. +#if __has_attribute(swiftcall) +#define SWIFT_CC_swift __attribute__((swiftcall)) +#define SWIFT_CONTEXT __attribute__((swift_context)) +#define SWIFT_ERROR_RESULT __attribute__((swift_error_result)) +#define SWIFT_INDIRECT_RESULT __attribute__((swift_indirect_result)) +#else +#define SWIFT_CC_swift +#define SWIFT_CONTEXT +#define SWIFT_ERROR_RESULT +#define SWIFT_INDIRECT_RESULT +#endif + +typedef struct _Job* JobRef; + +typedef SWIFT_CC(swift) void (*swift_task_enqueueGlobal_original)(JobRef _Nonnull job); +SWIFT_EXPORT_FROM(swift_Concurrency) +SWIFT_CC(swift) void (* _Nullable swift_task_enqueueGlobal_hook)( + JobRef _Nonnull job, swift_task_enqueueGlobal_original _Nonnull original); + diff --git a/Sources/_CAsyncSupport/module.modulemap b/Sources/_CAsyncSupport/module.modulemap new file mode 100644 index 000000000000..6b46887e76f7 --- /dev/null +++ b/Sources/_CAsyncSupport/module.modulemap @@ -0,0 +1,4 @@ +module _CAsyncSupport [system] { + header "_CAsyncSupport.h" + export * +} diff --git a/Sources/swift-composable-architecture-benchmark/Dependencies.swift b/Sources/swift-composable-architecture-benchmark/Dependencies.swift index 532144240a50..9b4e2882268d 100644 --- a/Sources/swift-composable-architecture-benchmark/Dependencies.swift +++ b/Sources/swift-composable-architecture-benchmark/Dependencies.swift @@ -5,22 +5,22 @@ import Dependencies import Foundation let dependenciesSuite = BenchmarkSuite(name: "Dependencies") { suite in - #if swift(>=5.7) - let reducer: some ReducerProtocol = BenchmarkReducer() - .dependency(\.calendar, .autoupdatingCurrent) - .dependency(\.date, .init { Date() }) - .dependency(\.locale, .autoupdatingCurrent) - .dependency(\.mainQueue, .immediate) - .dependency(\.mainRunLoop, .immediate) - .dependency(\.timeZone, .autoupdatingCurrent) - .dependency(\.uuid, .init { UUID() }) +#if swift(>=5.7) + let reducer: some ReducerProtocol = BenchmarkReducer() + .dependency(\.calendar, .autoupdatingCurrent) + .dependency(\.date, .init { Date() }) + .dependency(\.locale, .autoupdatingCurrent) + .dependency(\.mainQueue, .immediate) + .dependency(\.mainRunLoop, .immediate) + .dependency(\.timeZone, .autoupdatingCurrent) + .dependency(\.uuid, .init { UUID() }) - suite.benchmark("Dependency key writing") { - var state = 0 - _ = reducer.reduce(into: &state, action: ()) - precondition(state == 1) - } - #endif + suite.benchmark("Dependency key writing") { + var state = 0 + _ = reducer.reduce(into: &state, action: ()) + precondition(state == 1) + } +#endif } private struct BenchmarkReducer: ReducerProtocol { diff --git a/Tests/ComposableArchitectureTests/EffectTests.swift b/Tests/ComposableArchitectureTests/EffectTests.swift index 16e8cbf8c8f4..cfd26b542efb 100644 --- a/Tests/ComposableArchitectureTests/EffectTests.swift +++ b/Tests/ComposableArchitectureTests/EffectTests.swift @@ -1,5 +1,5 @@ import Combine -@_spi(Canary) import ComposableArchitecture +@_spi(Canary) @_spi(Internals) import ComposableArchitecture import XCTest @MainActor @@ -52,34 +52,36 @@ final class EffectTests: XCTestCase { #if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) func testConcatenate() async { - if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { - let clock = TestClock() - var values: [Int] = [] - - let effect = EffectPublisher.concatenate( - (1...3).map { count in - .task { - try await clock.sleep(for: .seconds(count)) - return count + await _withMainSerialExecutor { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + let clock = TestClock() + var values: [Int] = [] + + let effect = EffectPublisher.concatenate( + (1...3).map { count in + .task { + try await clock.sleep(for: .seconds(count)) + return count + } } - } - ) + ) - effect.sink(receiveValue: { values.append($0) }).store(in: &self.cancellables) + effect.sink(receiveValue: { values.append($0) }).store(in: &self.cancellables) - XCTAssertEqual(values, []) + XCTAssertEqual(values, []) - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values, [1]) + await clock.advance(by: .seconds(1)) + XCTAssertEqual(values, [1]) - await clock.advance(by: .seconds(2)) - XCTAssertEqual(values, [1, 2]) + await clock.advance(by: .seconds(2)) + XCTAssertEqual(values, [1, 2]) - await clock.advance(by: .seconds(3)) - XCTAssertEqual(values, [1, 2, 3]) + await clock.advance(by: .seconds(3)) + XCTAssertEqual(values, [1, 2, 3]) - await clock.run() - XCTAssertEqual(values, [1, 2, 3]) + await clock.run() + XCTAssertEqual(values, [1, 2, 3]) + } } } #endif @@ -335,25 +337,23 @@ final class EffectTests: XCTestCase { func testMap() async { @Dependency(\.date) var date - let effect = - DependencyValues - .withValue(\.date, .init { Date(timeIntervalSince1970: 1_234_567_890) }) { - EffectTask(value: ()) - .map { date() } - } + let effect = withDependencies { + $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) + } operation: { + EffectTask(value: ()).map { date() } + } var output: Date? effect .sink { output = $0 } .store(in: &self.cancellables) XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) - + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { - let effect = - DependencyValues - .withValue(\.date, .init { Date(timeIntervalSince1970: 1_234_567_890) }) { - EffectTask.task {} - .map { date() } - } + let effect = withDependencies { + $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) + } operation: { + EffectTask.task {}.map { date() } + } output = await effect.values.first(where: { _ in true }) XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) } diff --git a/Tests/DependenciesTests/DependencyKeyTests.swift b/Tests/DependenciesTests/DependencyKeyTests.swift deleted file mode 100644 index 2b9090899468..000000000000 --- a/Tests/DependenciesTests/DependencyKeyTests.swift +++ /dev/null @@ -1,158 +0,0 @@ -import ComposableArchitecture -import XCTest - -final class DependencyKeyTests: XCTestCase { - func testTestDependencyKey_ImplementOnlyTestValue() { - enum Key: TestDependencyKey { - static let testValue = 42 - } - - XCTAssertEqual(42, Key.previewValue) - XCTAssertEqual(42, Key.testValue) - } - - func testDependencyKeyCascading_ValueIsSelf_ImplementOnlyLiveValue() { - struct Dependency: DependencyKey { - let value: Int - static let liveValue = Self(value: 42) - } - - XCTAssertEqual(42, Dependency.liveValue.value) - XCTAssertEqual(42, Dependency.previewValue.value) - - #if DEBUG - XCTExpectFailure { - XCTAssertEqual(42, Dependency.testValue.value) - } issueMatcher: { issue in - issue.compactDescription == """ - A dependency has no test implementation, but was accessed from a test context: - - Dependency: - DependencyKeyTests.Dependency - - Dependencies registered with the library are not allowed to use their default, live \ - implementations when run from tests. - - To fix, override the dependency with a mock value in your test. If you are using the \ - Composable Architecture, mutate the 'dependencies' property on your 'TestStore'. \ - Otherwise, use 'DependencyValues.withValues' to define a scope for the override. If \ - you'd like to provide a default value for all tests, implement the 'testValue' \ - requirement of the 'DependencyKey' protocol. - """ - } - #endif - } - - func testDependencyKeyCascading_ImplementOnlyLiveValue() { - enum Key: DependencyKey { - static let liveValue = 42 - } - - XCTAssertEqual(42, Key.liveValue) - XCTAssertEqual(42, Key.previewValue) - - #if DEBUG - XCTExpectFailure { - XCTAssertEqual(42, Key.testValue) - } issueMatcher: { issue in - issue.compactDescription == """ - A dependency has no test implementation, but was accessed from a test context: - - Key: - DependencyKeyTests.Key - Value: - Int - - Dependencies registered with the library are not allowed to use their default, live \ - implementations when run from tests. - - To fix, override the dependency with a mock value in your test. If you are using the \ - Composable Architecture, mutate the 'dependencies' property on your 'TestStore'. \ - Otherwise, use 'DependencyValues.withValues' to define a scope for the override. If \ - you'd like to provide a default value for all tests, implement the 'testValue' \ - requirement of the 'DependencyKey' protocol. - """ - } - #endif - } - - func testDependencyKeyCascading_ImplementOnlyLiveAndPreviewValue() { - enum Key: DependencyKey { - static let liveValue = 42 - static let previewValue = 1729 - } - - XCTAssertEqual(42, Key.liveValue) - XCTAssertEqual(1729, Key.previewValue) - - #if DEBUG - XCTExpectFailure { - XCTAssertEqual(42, Key.testValue) - } issueMatcher: { issue in - issue.compactDescription == """ - A dependency has no test implementation, but was accessed from a test context: - - Key: - DependencyKeyTests.Key - Value: - Int - - Dependencies registered with the library are not allowed to use their default, live \ - implementations when run from tests. - - To fix, override the dependency with a mock value in your test. If you are using the \ - Composable Architecture, mutate the 'dependencies' property on your 'TestStore'. \ - Otherwise, use 'DependencyValues.withValues' to define a scope for the override. If \ - you'd like to provide a default value for all tests, implement the 'testValue' \ - requirement of the 'DependencyKey' protocol. - """ - } - #endif - } - - func testDependencyKeyCascading_ImplementOnlyLive_Named() { - #if DEBUG - DependencyValues.withValues { - $0.context = .test - } operation: { - @Dependency(\.missingTestDependency) var missingTestDependency: Int - let line = #line - 1 - XCTExpectFailure { - XCTAssertEqual(42, missingTestDependency) - } issueMatcher: { issue in - issue.compactDescription == """ - @Dependency(\\.missingTestDependency) has no test implementation, but was accessed \ - from a test context: - - Location: - DependenciesTests/DependencyKeyTests.swift:\(line) - Key: - LiveKey - Value: - Int - - Dependencies registered with the library are not allowed to use their default, live \ - implementations when run from tests. - - To fix, override 'missingTestDependency' with a mock value in your test. If you are \ - using the Composable Architecture, mutate the 'dependencies' property on your \ - 'TestStore'. Otherwise, use 'DependencyValues.withValues' to define a scope for the \ - override. If you'd like to provide a default value for all tests, implement the \ - 'testValue' requirement of the 'DependencyKey' protocol. - """ - } - } - #endif - } -} - -private enum LiveKey: DependencyKey { - static let liveValue = 42 -} - -extension DependencyValues { - fileprivate var missingTestDependency: Int { - get { self[LiveKey.self] } - set { self[LiveKey.self] = newValue } - } -} diff --git a/Tests/DependenciesTests/DependencyValuesTests.swift b/Tests/DependenciesTests/DependencyValuesTests.swift deleted file mode 100644 index 85e9060bd5c6..000000000000 --- a/Tests/DependenciesTests/DependencyValuesTests.swift +++ /dev/null @@ -1,275 +0,0 @@ -import Dependencies -import XCTest - -final class DependencyValuesTests: XCTestCase { - func testMissingLiveValue() { - #if DEBUG - var line = 0 - XCTExpectFailure { - DependencyValues.withValue(\.context, .live) { - line = #line + 1 - @Dependency(\.missingLiveDependency) var missingLiveDependency: Int - _ = missingLiveDependency - } - } issueMatcher: { - $0.compactDescription == """ - "@Dependency(\\.missingLiveDependency)" has no live implementation, but was accessed \ - from a live context. - - Location: - DependenciesTests/DependencyValuesTests.swift:\(line) - Key: - TestKey - Value: - Int - - Every dependency registered with the library must conform to "DependencyKey", and that \ - conformance must be visible to the running application. - - To fix, make sure that "TestKey" conforms to "DependencyKey" by providing a live \ - implementation of your dependency, and make sure that the conformance is linked with \ - this current application. - """ - } - #endif - } - - func testWithValues() { - let date = DependencyValues.withValues { - $0.date = .constant(someDate) - } operation: { () -> Date in - @Dependency(\.date) var date - return date.now - } - - let defaultDate = DependencyValues.withValues { - $0.context = .live - } operation: { () -> Date in - @Dependency(\.date) var date - return date.now - } - - XCTAssertEqual(date, someDate) - XCTAssertNotEqual(defaultDate, someDate) - } - - func testWithValue() { - DependencyValues.withValue(\.context, .live) { - let date = DependencyValues.withValue(\.date, .constant(someDate)) { () -> Date in - @Dependency(\.date) var date - return date.now - } - - XCTAssertEqual(date, someDate) - XCTAssertNotEqual(DependencyValues._current.date.now, someDate) - } - } - - func testDependencyDefaultIsReused() { - DependencyValues.withValue(\.self, .init()) { - DependencyValues.withValue(\.context, .test) { - @Dependency(\.reuseClient) var reuseClient: ReuseClient - - XCTAssertEqual(reuseClient.count(), 0) - reuseClient.setCount(42) - XCTAssertEqual(reuseClient.count(), 42) - } - } - } - - func testDependencyDefaultIsReused_SegmentedByContext() { - DependencyValues.withValue(\.self, .init()) { - DependencyValues.withValue(\.context, .test) { - @Dependency(\.reuseClient) var reuseClient: ReuseClient - - XCTAssertEqual(reuseClient.count(), 0) - reuseClient.setCount(42) - XCTAssertEqual(reuseClient.count(), 42) - - DependencyValues.withValue(\.context, .preview) { - XCTAssertEqual(reuseClient.count(), 0) - reuseClient.setCount(1729) - XCTAssertEqual(reuseClient.count(), 1729) - } - - XCTAssertEqual(reuseClient.count(), 42) - - DependencyValues.withValue(\.context, .live) { - #if DEBUG - XCTExpectFailure { - $0.compactDescription.contains( - """ - @Dependency(\\.reuseClient)" has no live implementation, but was accessed from a live \ - context. - """ - ) - } - #endif - XCTAssertEqual(reuseClient.count(), 0) - reuseClient.setCount(-42) - XCTAssertEqual( - reuseClient.count(), - 0, - "Don't cache dependency when using a test value in a live context" - ) - } - - XCTAssertEqual(reuseClient.count(), 42) - } - } - } - - func testAccessingTestDependencyFromLiveContext_WhenUpdatingDependencies() { - @Dependency(\.reuseClient) var reuseClient: ReuseClient - - DependencyValues.withValue(\.context, .live) { - DependencyValues.withValues { - XCTAssertEqual($0.reuseClient.count(), 0) - XCTAssertEqual(reuseClient.count(), 0) - } operation: { - #if DEBUG - XCTExpectFailure { - $0.compactDescription.contains( - """ - @Dependency(\\.reuseClient)" has no live implementation, but was accessed from a live \ - context. - """ - ) - } - #endif - XCTAssertEqual(reuseClient.count(), 0) - } - } - } - - func testBinding() { - DependencyValues.withValue(\.context, .test) { - @Dependency(\.childDependencyEarlyBinding) var childDependencyEarlyBinding: - ChildDependencyEarlyBinding - @Dependency(\.childDependencyLateBinding) var childDependencyLateBinding: - ChildDependencyLateBinding - - XCTAssertEqual(childDependencyEarlyBinding.fetch(), 42) - XCTAssertEqual(childDependencyLateBinding.fetch(), 42) - - DependencyValues.withValue(\.someDependency.fetch, { 1729 }) { - XCTAssertEqual(childDependencyEarlyBinding.fetch(), 1729) - XCTAssertEqual(childDependencyLateBinding.fetch(), 1729) - } - - var childDependencyEarlyBindingEscaped: ChildDependencyEarlyBinding! - var childDependencyLateBindingEscaped: ChildDependencyLateBinding! - - DependencyValues.withValue(\.someDependency.fetch, { 999 }) { - @Dependency(\.childDependencyEarlyBinding) var childDependencyEarlyBinding2: - ChildDependencyEarlyBinding - @Dependency(\.childDependencyLateBinding) var childDependencyLateBinding2: - ChildDependencyLateBinding - - childDependencyEarlyBindingEscaped = childDependencyEarlyBinding - childDependencyLateBindingEscaped = childDependencyLateBinding - - XCTAssertEqual(childDependencyEarlyBinding2.fetch(), 999) - XCTAssertEqual(childDependencyLateBinding2.fetch(), 999) - } - - XCTAssertEqual(childDependencyEarlyBindingEscaped.fetch(), 42) - XCTAssertEqual(childDependencyLateBindingEscaped.fetch(), 42) - - DependencyValues.withValue(\.someDependency.fetch, { 1_000 }) { - XCTAssertEqual(childDependencyEarlyBindingEscaped.fetch(), 1_000) - XCTAssertEqual(childDependencyLateBindingEscaped.fetch(), 1_000) - } - } - } - - func testNestedDependencyIsOverridden() { - DependencyValues.withValue(\.nestedValue.value, 10) { - @Dependency(\.nestedValue) var nestedValue: NestedValue - @Dependency(\.nestedValue.value) var value: Int - XCTAssertEqual(nestedValue.value, 10) - XCTAssertEqual(value, 10) - } - } -} - -struct SomeDependency: TestDependencyKey { - var fetch: () -> Int - static let testValue = Self { 42 } -} -struct ChildDependencyEarlyBinding: TestDependencyKey { - var fetch: () -> Int - static var testValue: Self { - @Dependency(\.someDependency) var someDependency - return Self { someDependency.fetch() } - } -} -struct ChildDependencyLateBinding: TestDependencyKey { - var fetch: () -> Int - static var testValue: Self { - return Self { - @Dependency(\.someDependency) var someDependency - return someDependency.fetch() - } - } -} -struct NestedValue: TestDependencyKey { - static var testValue: Self { .init() } - var value: Int = 0 -} - -extension DependencyValues { - var someDependency: SomeDependency { - get { self[SomeDependency.self] } - set { self[SomeDependency.self] = newValue } - } - var childDependencyEarlyBinding: ChildDependencyEarlyBinding { - get { self[ChildDependencyEarlyBinding.self] } - set { self[ChildDependencyEarlyBinding.self] = newValue } - } - var childDependencyLateBinding: ChildDependencyLateBinding { - get { self[ChildDependencyLateBinding.self] } - set { self[ChildDependencyLateBinding.self] = newValue } - } - var nestedValue: NestedValue { - get { self[NestedValue.self] } - set { self[NestedValue.self] = newValue } - } -} - -private let someDate = Date(timeIntervalSince1970: 1_234_567_890) - -extension DependencyValues { - fileprivate var missingLiveDependency: Int { - self[TestKey.self] - } -} - -private enum TestKey: TestDependencyKey { - static let testValue = 42 -} - -extension DependencyValues { - fileprivate var reuseClient: ReuseClient { - get { self[ReuseClient.self] } - set { self[ReuseClient.self] = newValue } - } -} -struct ReuseClient: TestDependencyKey { - var count: () -> Int - var setCount: (Int) -> Void - init( - count: @escaping () -> Int, - setCount: @escaping (Int) -> Void - ) { - self.count = count - self.setCount = setCount - } - static var testValue: Self { - var count = 0 - return Self( - count: { count }, - setCount: { count = $0 } - ) - } -} From 8f356ef0a118dc5b6d0977f83f02667ade053398 Mon Sep 17 00:00:00 2001 From: stephencelis Date: Mon, 9 Jan 2023 16:21:04 +0000 Subject: [PATCH 02/38] Run swift-format --- Package.swift | 2 +- Sources/ComposableArchitecture/Effect.swift | 32 ++++++++++--------- .../Effects/Publisher.swift | 26 ++++++++------- .../Dependencies.swift | 30 ++++++++--------- .../EffectTests.swift | 4 +-- 5 files changed, 50 insertions(+), 44 deletions(-) diff --git a/Package.swift b/Package.swift index 527d62a4286f..0105a2dd0e71 100644 --- a/Package.swift +++ b/Package.swift @@ -14,7 +14,7 @@ let package = Package( .library( name: "ComposableArchitecture", targets: ["ComposableArchitecture"] - ), + ) ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index a65dc46c322e..63506f98a3e5 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -506,29 +506,31 @@ extension EffectPublisher { return .init( operation: .publisher( publisher - .map(withEscapedDependencies { escaped in - { action in - escaped.yield { - transform(action) + .map( + withEscapedDependencies { escaped in + { action in + escaped.yield { + transform(action) + } } } - }) + ) .eraseToAnyPublisher() ) ) case let .run(priority, operation): return withEscapedDependencies { escaped in - .init( - operation: .run(priority) { send in - await escaped.yield { - await operation( - Send { action in - send(transform(action)) - } - ) - } + .init( + operation: .run(priority) { send in + await escaped.yield { + await operation( + Send { action in + send(transform(action)) + } + ) } - ) + } + ) } } } diff --git a/Sources/ComposableArchitecture/Effects/Publisher.swift b/Sources/ComposableArchitecture/Effects/Publisher.swift index a0f29018a7fa..a51658ab0a2c 100644 --- a/Sources/ComposableArchitecture/Effects/Publisher.swift +++ b/Sources/ComposableArchitecture/Effects/Publisher.swift @@ -394,14 +394,16 @@ extension Publisher { public func eraseToEffect( _ transform: @escaping (Output) -> T ) -> EffectPublisher { - self.map(withEscapedDependencies { escaped in - { action in - escaped.yield { - transform(action) + self.map( + withEscapedDependencies { escaped in + { action in + escaped.yield { + transform(action) + } } } - }) - .eraseToEffect() + ) + .eraseToEffect() } /// Turns any publisher into an ``EffectTask`` that cannot fail by wrapping its output and failure @@ -474,13 +476,15 @@ extension Publisher { ) -> EffectTask { return self - .map(withEscapedDependencies { escaped in - { action in - escaped.yield { - transform(.success(action)) + .map( + withEscapedDependencies { escaped in + { action in + escaped.yield { + transform(.success(action)) + } } } - }) + ) .catch { Just(transform(.failure($0))) } .eraseToEffect() } diff --git a/Sources/swift-composable-architecture-benchmark/Dependencies.swift b/Sources/swift-composable-architecture-benchmark/Dependencies.swift index 9b4e2882268d..532144240a50 100644 --- a/Sources/swift-composable-architecture-benchmark/Dependencies.swift +++ b/Sources/swift-composable-architecture-benchmark/Dependencies.swift @@ -5,22 +5,22 @@ import Dependencies import Foundation let dependenciesSuite = BenchmarkSuite(name: "Dependencies") { suite in -#if swift(>=5.7) - let reducer: some ReducerProtocol = BenchmarkReducer() - .dependency(\.calendar, .autoupdatingCurrent) - .dependency(\.date, .init { Date() }) - .dependency(\.locale, .autoupdatingCurrent) - .dependency(\.mainQueue, .immediate) - .dependency(\.mainRunLoop, .immediate) - .dependency(\.timeZone, .autoupdatingCurrent) - .dependency(\.uuid, .init { UUID() }) + #if swift(>=5.7) + let reducer: some ReducerProtocol = BenchmarkReducer() + .dependency(\.calendar, .autoupdatingCurrent) + .dependency(\.date, .init { Date() }) + .dependency(\.locale, .autoupdatingCurrent) + .dependency(\.mainQueue, .immediate) + .dependency(\.mainRunLoop, .immediate) + .dependency(\.timeZone, .autoupdatingCurrent) + .dependency(\.uuid, .init { UUID() }) - suite.benchmark("Dependency key writing") { - var state = 0 - _ = reducer.reduce(into: &state, action: ()) - precondition(state == 1) - } -#endif + suite.benchmark("Dependency key writing") { + var state = 0 + _ = reducer.reduce(into: &state, action: ()) + precondition(state == 1) + } + #endif } private struct BenchmarkReducer: ReducerProtocol { diff --git a/Tests/ComposableArchitectureTests/EffectTests.swift b/Tests/ComposableArchitectureTests/EffectTests.swift index cfd26b542efb..8da517904580 100644 --- a/Tests/ComposableArchitectureTests/EffectTests.swift +++ b/Tests/ComposableArchitectureTests/EffectTests.swift @@ -1,5 +1,5 @@ import Combine -@_spi(Canary) @_spi(Internals) import ComposableArchitecture +@_spi(Canary)@_spi(Internals) import ComposableArchitecture import XCTest @MainActor @@ -347,7 +347,7 @@ final class EffectTests: XCTestCase { .sink { output = $0 } .store(in: &self.cancellables) XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) - + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { let effect = withDependencies { $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) From f1d62bc41358ce5843c55bb1ee7106066bc83862 Mon Sep 17 00:00:00 2001 From: Thomas Grapperon <35562418+tgrapperon@users.noreply.github.com> Date: Mon, 9 Jan 2023 13:37:10 -0300 Subject: [PATCH 03/38] Fix UIKit's "Navigate and Load" study (#1807) --- Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift b/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift index 102a51e487fa..6e67fb40ef43 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift @@ -42,6 +42,9 @@ struct EagerNavigation: ReducerProtocol { return .none } } + .ifLet(\.optionalCounter, action: /Action.optionalCounter) { + Counter() + } } } From be4940de78d65a9bd34de12f9b4457e27dec68a8 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Mon, 9 Jan 2023 11:10:09 -0800 Subject: [PATCH 04/38] Move test helper to test target. (#1809) --- Package.swift | 2 +- .../ComposableArchitectureTests}/SerialExecutor.swift | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {Sources/ComposableArchitecture/Internal => Tests/ComposableArchitectureTests}/SerialExecutor.swift (100%) diff --git a/Package.swift b/Package.swift index 0105a2dd0e71..499ab1a99618 100644 --- a/Package.swift +++ b/Package.swift @@ -31,7 +31,6 @@ let package = Package( .target( name: "ComposableArchitecture", dependencies: [ - "_CAsyncSupport", .product(name: "CasePaths", package: "swift-case-paths"), .product(name: "CombineSchedulers", package: "combine-schedulers"), .product(name: "CustomDump", package: "swift-custom-dump"), @@ -44,6 +43,7 @@ let package = Package( .testTarget( name: "ComposableArchitectureTests", dependencies: [ + "_CAsyncSupport", "ComposableArchitecture" ] ), diff --git a/Sources/ComposableArchitecture/Internal/SerialExecutor.swift b/Tests/ComposableArchitectureTests/SerialExecutor.swift similarity index 100% rename from Sources/ComposableArchitecture/Internal/SerialExecutor.swift rename to Tests/ComposableArchitectureTests/SerialExecutor.swift From 1da4298d0bbad22b63596087cacff495491e41d8 Mon Sep 17 00:00:00 2001 From: mbrandonw Date: Mon, 9 Jan 2023 20:01:13 +0000 Subject: [PATCH 05/38] Run swift-format --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 499ab1a99618..399b7163f76d 100644 --- a/Package.swift +++ b/Package.swift @@ -44,7 +44,7 @@ let package = Package( name: "ComposableArchitectureTests", dependencies: [ "_CAsyncSupport", - "ComposableArchitecture" + "ComposableArchitecture", ] ), .executableTarget( From 5811712ee665e8837d308f8b77f28424a5e65464 Mon Sep 17 00:00:00 2001 From: Thomas Grapperon <35562418+tgrapperon@users.noreply.github.com> Date: Tue, 10 Jan 2023 15:14:47 -0300 Subject: [PATCH 06/38] Bump `Dependencies` to 0.1.2 (#1813) * Bump `Dependencies` to 0.1.2 * wip --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Package.resolved | 8 ++++---- Package.swift | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved index 55483553b50e..0ecad7b19944 100644 --- a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "e49dfe4d9e4c5c06f3334361360b801aef41631c", - "version" : "0.1.1" + "revision" : "e9e82b5302025092ab8358e794f89a0f0397dd9d", + "version" : "0.1.2" } }, { diff --git a/Package.resolved b/Package.resolved index 1a451627d11f..8b39bacc9fdb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "e49dfe4d9e4c5c06f3334361360b801aef41631c", - "version" : "0.1.1" + "revision" : "e9e82b5302025092ab8358e794f89a0f0397dd9d", + "version" : "0.1.2" } }, { @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "46acf5ecc1cabdb28d7fe03289f6c8b13a023f52", - "version" : "0.4.5" + "revision" : "ddc01cdcddfd30ef7a966049b2e1d251e224ad93", + "version" : "0.5.0" } }, { diff --git a/Package.swift b/Package.swift index 399b7163f76d..d6ca572cf2c2 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.8.0"), .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.10.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.6.0"), - .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.1.1"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.1.2"), .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "0.4.1"), .package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "0.4.5"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.5.0"), From 57b3012b54f59b96050d9597e2084a1ba073a01a Mon Sep 17 00:00:00 2001 From: Pat Brown Date: Wed, 11 Jan 2023 06:54:31 +1100 Subject: [PATCH 07/38] Wrap view store binding (#1802) --- Sources/ComposableArchitecture/ViewStore.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index 65504b750395..d3dee3728321 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -466,8 +466,10 @@ public final class ViewStore: ObservableObject { get: @escaping (ViewState) -> Value, send valueToAction: @escaping (Value) -> ViewAction ) -> Binding { - ObservedObject(wrappedValue: self) + let base = ObservedObject(wrappedValue: self) .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] + + return Binding(get: { base.wrappedValue }, set: { base.transaction($1).wrappedValue = $0 }) } /// Derives a binding from the store that prevents direct writes to state and instead sends From 6cf778a7da64de9508596a435aa0a5647885d71a Mon Sep 17 00:00:00 2001 From: stephencelis Date: Tue, 10 Jan 2023 20:08:39 +0000 Subject: [PATCH 08/38] Run swift-format --- Sources/ComposableArchitecture/ViewStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index d3dee3728321..d206dc519de9 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -468,7 +468,7 @@ public final class ViewStore: ObservableObject { ) -> Binding { let base = ObservedObject(wrappedValue: self) .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] - + return Binding(get: { base.wrappedValue }, set: { base.transaction($1).wrappedValue = $0 }) } From f2f3a2558d5d63108420b5413d2cc94a850f7b92 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 10 Jan 2023 17:43:43 -0500 Subject: [PATCH 09/38] Remove and update a few deprecated, flakey tests (#1816) * Remove and update a few deprecated, flakey tests * wip --- .../BindingTests.swift | 2 +- .../CompatibilityTests.swift | 14 +-- .../ComposableArchitectureTests.swift | 10 +- .../MemoryManagementTests.swift | 6 +- .../ReducerTests.swift | 116 ------------------ .../RuntimeWarningTests.swift | 8 +- .../StoreTests.swift | 12 +- .../ViewStoreTests.swift | 20 +-- 8 files changed, 32 insertions(+), 156 deletions(-) diff --git a/Tests/ComposableArchitectureTests/BindingTests.swift b/Tests/ComposableArchitectureTests/BindingTests.swift index e324ef24f0ee..db0033f06e8d 100644 --- a/Tests/ComposableArchitectureTests/BindingTests.swift +++ b/Tests/ComposableArchitectureTests/BindingTests.swift @@ -34,7 +34,7 @@ final class BindingTests: XCTestCase { let store = Store(initialState: BindingTest.State(), reducer: BindingTest()) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) viewStore.binding(\.$nested.field).wrappedValue = "Hello" diff --git a/Tests/ComposableArchitectureTests/CompatibilityTests.swift b/Tests/ComposableArchitectureTests/CompatibilityTests.swift index e1b83d4a4bd4..2bfe865d7b3b 100644 --- a/Tests/ComposableArchitectureTests/CompatibilityTests.swift +++ b/Tests/ComposableArchitectureTests/CompatibilityTests.swift @@ -36,7 +36,7 @@ final class CompatibilityTests: XCTestCase { var handledActions: [String] = [] - let reducer = AnyReducer { state, action, env in + let reducer = Reduce { state, action in handledActions.append(action.description) switch action { @@ -59,11 +59,10 @@ final class CompatibilityTests: XCTestCase { let store = Store( initialState: .init(), - reducer: reducer, - environment: () + reducer: reducer ) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) viewStore.send(.start) viewStore.send(.kickOffAction) @@ -89,14 +88,13 @@ final class CompatibilityTests: XCTestCase { func testCaseStudy_ActionReentranceFromStateObservation() { let store = Store( initialState: 0, - reducer: .init { state, action, _ in + reducer: Reduce { state, action in state = action return .none - }, - environment: () + } ) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) viewStore.publisher .sink { value in diff --git a/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift b/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift index d4db123f264d..99f1cf7032c1 100644 --- a/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift +++ b/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift @@ -79,11 +79,6 @@ final class ComposableArchitectureTests: XCTestCase { } func testLongLivingEffects() async { - typealias Environment = ( - startEffect: EffectTask, - stopEffect: EffectTask - ) - enum Action { case end, incr, start } let effect = AsyncStream.streamWithContinuation() @@ -124,7 +119,7 @@ final class ComposableArchitectureTests: XCTestCase { case response(Int) } - let reducer = AnyReducer { state, action, _ in + let reducer = Reduce { state, action in enum CancelID {} switch action { @@ -147,8 +142,7 @@ final class ComposableArchitectureTests: XCTestCase { let store = TestStore( initialState: 0, - reducer: reducer, - environment: () + reducer: reducer ) await store.send(.incr) { $0 = 1 } diff --git a/Tests/ComposableArchitectureTests/MemoryManagementTests.swift b/Tests/ComposableArchitectureTests/MemoryManagementTests.swift index 8384d0de1ef6..1e8e37f1d759 100644 --- a/Tests/ComposableArchitectureTests/MemoryManagementTests.swift +++ b/Tests/ComposableArchitectureTests/MemoryManagementTests.swift @@ -13,7 +13,7 @@ final class MemoryManagementTests: XCTestCase { let store = Store(initialState: 0, reducer: counterReducer) .scope(state: { "\($0)" }) .scope(state: { Int($0)! }) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) var count = 0 viewStore.publisher.sink { count = $0 }.store(in: &self.cancellables) @@ -28,7 +28,7 @@ final class MemoryManagementTests: XCTestCase { state += 1 return .none } - let viewStore = ViewStore(Store(initialState: 0, reducer: counterReducer)) + let viewStore = ViewStore(Store(initialState: 0, reducer: counterReducer), observe: { $0 }) var count = 0 viewStore.publisher.sink { count = $0 }.store(in: &self.cancellables) @@ -57,7 +57,7 @@ final class MemoryManagementTests: XCTestCase { } } ) - let viewStore = ViewStore(store.scope(state: { $0 }).scope(state: { $0 })) + let viewStore = ViewStore(store.scope(state: { $0 }).scope(state: { $0 }), observe: { $0 }) var values: [Bool] = [] viewStore.publisher diff --git a/Tests/ComposableArchitectureTests/ReducerTests.swift b/Tests/ComposableArchitectureTests/ReducerTests.swift index ccfd50923cf1..eb9930f6ec36 100644 --- a/Tests/ComposableArchitectureTests/ReducerTests.swift +++ b/Tests/ComposableArchitectureTests/ReducerTests.swift @@ -110,122 +110,6 @@ final class ReducerTests: XCTestCase { XCTAssertTrue(second) } - #if DEBUG - func testDebug() async { - enum DebugAction: Equatable { - case incrWithBool(Bool) - case incr, noop - } - struct DebugState: Equatable { var count = 0 } - - var logs: [String] = [] - let logsExpectation = self.expectation(description: "logs") - logsExpectation.expectedFulfillmentCount = 2 - - let reducer = AnyReducer { state, action, _ in - switch action { - case .incrWithBool: - return .none - case .incr: - state.count += 1 - return .none - case .noop: - return .none - } - } - .debug("[prefix]") { _ in - DebugEnvironment( - printer: { - logs.append($0) - logsExpectation.fulfill() - } - ) - } - - let store = TestStore( - initialState: .init(), - reducer: reducer, - environment: () - ) - await store.send(.incr) { $0.count = 1 } - await store.send(.noop) - - self.wait(for: [logsExpectation], timeout: 5) - - XCTAssertEqual( - logs, - [ - #""" - [prefix]: received action: - ReducerTests.DebugAction.incr - - ReducerTests.DebugState(count: 0) - + ReducerTests.DebugState(count: 1) - - """#, - #""" - [prefix]: received action: - ReducerTests.DebugAction.noop - (No state changes) - - """#, - ] - ) - } - - func testDebug_ActionFormat_OnlyLabels() { - enum DebugAction: Equatable { - case incrWithBool(Bool) - case incr, noop - } - struct DebugState: Equatable { var count = 0 } - - var logs: [String] = [] - let logsExpectation = self.expectation(description: "logs") - - let reducer = AnyReducer { state, action, _ in - switch action { - case let .incrWithBool(bool): - state.count += bool ? 1 : 0 - return .none - default: - return .none - } - } - .debug("[prefix]", actionFormat: .labelsOnly) { _ in - DebugEnvironment( - printer: { - logs.append($0) - logsExpectation.fulfill() - } - ) - } - - let viewStore = ViewStore( - Store( - initialState: .init(), - reducer: reducer, - environment: () - ) - ) - viewStore.send(.incrWithBool(true)) - - self.wait(for: [logsExpectation], timeout: 5) - - XCTAssertEqual( - logs, - [ - #""" - [prefix]: received action: - ReducerTests.DebugAction.incrWithBool - - ReducerTests.DebugState(count: 0) - + ReducerTests.DebugState(count: 1) - - """# - ] - ) - } - #endif - func testDefaultSignpost() { let reducer = EmptyReducer().signpost(log: .default) var n = 0 diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index 498ff93a08e4..482f96a68566 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -50,7 +50,7 @@ } } ) - ViewStore(store).send(.tap) + ViewStore(store, observe: { $0 }).send(.tap) _ = XCTWaiter.wait(for: [.init()], timeout: 0.5) } @@ -107,7 +107,7 @@ let store = Store(initialState: 0, reducer: EmptyReducer()) Task { - ViewStore(store).send(()) + ViewStore(store, observe: { $0 }).send(()) } _ = XCTWaiter.wait(for: [.init()], timeout: 0.5) } @@ -183,7 +183,7 @@ } } ) - await ViewStore(store).send(.tap).finish() + await ViewStore(store, observe: { $0 }).send(.tap).finish() } #endif @@ -203,7 +203,7 @@ var line: UInt = 0 XCTExpectFailure { line = #line - ViewStore(store).binding(\.$value).wrappedValue = 42 + ViewStore(store, observe: { $0 }).binding(\.$value).wrappedValue = 42 } issueMatcher: { $0.compactDescription == """ A binding action sent from a view store at "\(#fileID):\(line + 1)" was not handled. … diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index 9276613582ec..7c5265f56d8a 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -203,7 +203,7 @@ final class StoreTests: XCTestCase { let store = Store(initialState: (), reducer: counterReducer) - _ = ViewStore(store).send(.tap) + _ = ViewStore(store, observe: {}, removeDuplicates: ==).send(.tap) XCTAssertEqual(values, [1, 2, 3, 4]) } @@ -221,8 +221,8 @@ final class StoreTests: XCTestCase { }) let store = Store(initialState: 0, reducer: reducer) - _ = ViewStore(store).send(.incr) - XCTAssertEqual(ViewStore(store).state, 100_000) + _ = ViewStore(store, observe: { $0 }).send(.incr) + XCTAssertEqual(ViewStore(store, observe: { $0 }).state, 100_000) } func testIfLetAfterScope() { @@ -247,7 +247,7 @@ final class StoreTests: XCTestCase { .ifLet( then: { store in stores.append(store) - outputs.append(ViewStore(store).state) + outputs.append(ViewStore(store, observe: { $0 }).state) }, else: { outputs.append(nil) @@ -367,7 +367,7 @@ final class StoreTests: XCTestCase { ) var emissions: [Int] = [] - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) viewStore.publisher .sink { emissions.append($0) } .store(in: &self.cancellables) @@ -537,6 +537,6 @@ final class StoreTests: XCTestCase { .dependency(\.urlSession, URLSession(configuration: .ephemeral)) ) - ViewStore(store).send(true) + ViewStore(store, observe: { $0 }).send(true) } } diff --git a/Tests/ComposableArchitectureTests/ViewStoreTests.swift b/Tests/ComposableArchitectureTests/ViewStoreTests.swift index 3808c5220c1e..b36e311f070e 100644 --- a/Tests/ComposableArchitectureTests/ViewStoreTests.swift +++ b/Tests/ComposableArchitectureTests/ViewStoreTests.swift @@ -18,7 +18,7 @@ final class ViewStoreTests: XCTestCase { reducer: EmptyReducer() ) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) var emissionCount = 0 viewStore.publisher @@ -82,7 +82,7 @@ final class ViewStoreTests: XCTestCase { } let store = Store(initialState: 0, reducer: reducer) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) var results: [Int] = [] @@ -104,7 +104,7 @@ final class ViewStoreTests: XCTestCase { } let store = Store(initialState: 0, reducer: reducer) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) var results: [Int] = [] @@ -127,12 +127,12 @@ final class ViewStoreTests: XCTestCase { let store = Store(initialState: 0, reducer: reducer) var results: [Int] = [] - ViewStore(store) + ViewStore(store, observe: { $0 }) .publisher .sink { results.append($0) } .store(in: &self.cancellables) - ViewStore(store).send(()) + ViewStore(store, observe: { $0 }).send(()) XCTAssertEqual(results, [0, 1]) } @@ -142,7 +142,7 @@ final class ViewStoreTests: XCTestCase { return .none } let store = Store(initialState: 0, reducer: reducer) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) var results: [Int] = [] @@ -186,7 +186,7 @@ final class ViewStoreTests: XCTestCase { } let store = Store(initialState: false, reducer: reducer) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) XCTAssertEqual(viewStore.state, false) await viewStore.send(.tapped, while: { $0 }) @@ -215,7 +215,7 @@ final class ViewStoreTests: XCTestCase { } let store = Store(initialState: false, reducer: reducer) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) XCTAssertEqual(viewStore.state, false) _ = { viewStore.send(.tapped) }() @@ -247,7 +247,7 @@ final class ViewStoreTests: XCTestCase { } ) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) XCTAssertEqual(viewStore.state, 0) await viewStore.send(.tap).finish() @@ -275,7 +275,7 @@ final class ViewStoreTests: XCTestCase { } ) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store, observe: { $0 }) XCTAssertEqual(viewStore.state, 0) let task = viewStore.send(.tap) From 82d2b7807419fd2982ffcc8949f4e9775cc1bd97 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 10 Jan 2023 14:44:01 -0800 Subject: [PATCH 10/38] UI test to catch SwiftUI regressions (#1815) * Add some UI tests to catch regressions. * wip * wip --- .github/workflows/ci.yml | 1 - .../contents.xcworkspacedata | 3 + .../Integration.xcodeproj/project.pbxproj | 500 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 113 ++++ .../xcschemes/Integration.xcscheme | 88 +++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Integration/Assets.xcassets/Contents.json | 6 + .../Integration/ForEachBindingTestCase.swift | 55 ++ .../Integration/IntegrationApp.swift | 36 ++ .../NavigationStackBindingTestCase.swift | 55 ++ .../Preview Assets.xcassets/Contents.json | 6 + .../ForEachBindingTests.swift | 19 + .../NavigationStackBindingTests.swift | 17 + Makefile | 6 +- 17 files changed, 939 insertions(+), 5 deletions(-) create mode 100644 Examples/Integration/Integration.xcodeproj/project.pbxproj create mode 100644 Examples/Integration/Integration.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/Integration/Integration.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/Integration/Integration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/Integration/Integration.xcodeproj/xcshareddata/xcschemes/Integration.xcscheme create mode 100644 Examples/Integration/Integration/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/Integration/Integration/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/Integration/Integration/Assets.xcassets/Contents.json create mode 100644 Examples/Integration/Integration/ForEachBindingTestCase.swift create mode 100644 Examples/Integration/Integration/IntegrationApp.swift create mode 100644 Examples/Integration/Integration/NavigationStackBindingTestCase.swift create mode 100644 Examples/Integration/Integration/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 Examples/Integration/IntegrationUITests/ForEachBindingTests.swift create mode 100644 Examples/Integration/IntegrationUITests/NavigationStackBindingTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97ca4fe450b5..378a1702c24e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - swift-dependencies pull_request: branches: - '*' diff --git a/ComposableArchitecture.xcworkspace/contents.xcworkspacedata b/ComposableArchitecture.xcworkspace/contents.xcworkspacedata index 5cc16120ad09..c94a9453f398 100644 --- a/ComposableArchitecture.xcworkspace/contents.xcworkspacedata +++ b/ComposableArchitecture.xcworkspace/contents.xcworkspacedata @@ -7,6 +7,9 @@ + + diff --git a/Examples/Integration/Integration.xcodeproj/project.pbxproj b/Examples/Integration/Integration.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..d7d2e1112d55 --- /dev/null +++ b/Examples/Integration/Integration.xcodeproj/project.pbxproj @@ -0,0 +1,500 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + CA595273296DF46D00B5B695 /* NavigationStackBindingTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA595272296DF46D00B5B695 /* NavigationStackBindingTestCase.swift */; }; + CA595275296DF55A00B5B695 /* NavigationStackBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA595274296DF55A00B5B695 /* NavigationStackBindingTests.swift */; }; + CA595278296DF67E00B5B695 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = CA595277296DF67E00B5B695 /* ComposableArchitecture */; }; + CAA1CAF5296DEE78000665B1 /* IntegrationApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CAF4296DEE78000665B1 /* IntegrationApp.swift */; }; + CAA1CAF9296DEE79000665B1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAA1CAF8296DEE79000665B1 /* Assets.xcassets */; }; + CAA1CAFC296DEE79000665B1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAA1CAFB296DEE79000665B1 /* Preview Assets.xcassets */; }; + CAA1CB10296DEE79000665B1 /* ForEachBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB0F296DEE79000665B1 /* ForEachBindingTests.swift */; }; + CAA1CB1F296DEEAC000665B1 /* ForEachBindingTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB1E296DEEAC000665B1 /* ForEachBindingTestCase.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + CAA1CB0C296DEE79000665B1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CAA1CAE9296DEE78000665B1 /* Project object */; + proxyType = 1; + remoteGlobalIDString = CAA1CAF0296DEE78000665B1; + remoteInfo = Integration; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + CA595272296DF46D00B5B695 /* NavigationStackBindingTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackBindingTestCase.swift; sourceTree = ""; }; + CA595274296DF55A00B5B695 /* NavigationStackBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackBindingTests.swift; sourceTree = ""; }; + CA595276296DF66B00B5B695 /* swift-composable-architecture */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "swift-composable-architecture"; path = ../..; sourceTree = ""; }; + CAA1CAF1296DEE78000665B1 /* Integration.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Integration.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CAA1CAF4296DEE78000665B1 /* IntegrationApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationApp.swift; sourceTree = ""; }; + CAA1CAF8296DEE79000665B1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + CAA1CAFB296DEE79000665B1 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + CAA1CB0B296DEE79000665B1 /* IntegrationUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CAA1CB0F296DEE79000665B1 /* ForEachBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForEachBindingTests.swift; sourceTree = ""; }; + CAA1CB1E296DEEAC000665B1 /* ForEachBindingTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForEachBindingTestCase.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + CAA1CAEE296DEE78000665B1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CA595278296DF67E00B5B695 /* ComposableArchitecture in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CAA1CB08296DEE79000665B1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + CAA1CAE8296DEE78000665B1 = { + isa = PBXGroup; + children = ( + CA595276296DF66B00B5B695 /* swift-composable-architecture */, + CAA1CAF3296DEE78000665B1 /* Integration */, + CAA1CB0E296DEE79000665B1 /* IntegrationUITests */, + CAA1CAF2296DEE78000665B1 /* Products */, + CAA1CB20296DEEE2000665B1 /* Frameworks */, + ); + sourceTree = ""; + }; + CAA1CAF2296DEE78000665B1 /* Products */ = { + isa = PBXGroup; + children = ( + CAA1CAF1296DEE78000665B1 /* Integration.app */, + CAA1CB0B296DEE79000665B1 /* IntegrationUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + CAA1CAF3296DEE78000665B1 /* Integration */ = { + isa = PBXGroup; + children = ( + CAA1CB1E296DEEAC000665B1 /* ForEachBindingTestCase.swift */, + CAA1CAF4296DEE78000665B1 /* IntegrationApp.swift */, + CA595272296DF46D00B5B695 /* NavigationStackBindingTestCase.swift */, + CAA1CAF8296DEE79000665B1 /* Assets.xcassets */, + CAA1CAFA296DEE79000665B1 /* Preview Content */, + ); + path = Integration; + sourceTree = ""; + }; + CAA1CAFA296DEE79000665B1 /* Preview Content */ = { + isa = PBXGroup; + children = ( + CAA1CAFB296DEE79000665B1 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + CAA1CB0E296DEE79000665B1 /* IntegrationUITests */ = { + isa = PBXGroup; + children = ( + CAA1CB0F296DEE79000665B1 /* ForEachBindingTests.swift */, + CA595274296DF55A00B5B695 /* NavigationStackBindingTests.swift */, + ); + path = IntegrationUITests; + sourceTree = ""; + }; + CAA1CB20296DEEE2000665B1 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + CAA1CAF0296DEE78000665B1 /* Integration */ = { + isa = PBXNativeTarget; + buildConfigurationList = CAA1CB15296DEE79000665B1 /* Build configuration list for PBXNativeTarget "Integration" */; + buildPhases = ( + CAA1CAED296DEE78000665B1 /* Sources */, + CAA1CAEE296DEE78000665B1 /* Frameworks */, + CAA1CAEF296DEE78000665B1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CA59527A296DF6D000B5B695 /* PBXTargetDependency */, + ); + name = Integration; + packageProductDependencies = ( + CA595277296DF67E00B5B695 /* ComposableArchitecture */, + ); + productName = Integration; + productReference = CAA1CAF1296DEE78000665B1 /* Integration.app */; + productType = "com.apple.product-type.application"; + }; + CAA1CB0A296DEE79000665B1 /* IntegrationUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CAA1CB1B296DEE79000665B1 /* Build configuration list for PBXNativeTarget "IntegrationUITests" */; + buildPhases = ( + CAA1CB07296DEE79000665B1 /* Sources */, + CAA1CB08296DEE79000665B1 /* Frameworks */, + CAA1CB09296DEE79000665B1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CAA1CB0D296DEE79000665B1 /* PBXTargetDependency */, + ); + name = IntegrationUITests; + productName = IntegrationUITests; + productReference = CAA1CB0B296DEE79000665B1 /* IntegrationUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + CAA1CAE9296DEE78000665B1 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1420; + LastUpgradeCheck = 1420; + TargetAttributes = { + CAA1CAF0296DEE78000665B1 = { + CreatedOnToolsVersion = 14.2; + }; + CAA1CB0A296DEE79000665B1 = { + CreatedOnToolsVersion = 14.2; + TestTargetID = CAA1CAF0296DEE78000665B1; + }; + }; + }; + buildConfigurationList = CAA1CAEC296DEE78000665B1 /* Build configuration list for PBXProject "Integration" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = CAA1CAE8296DEE78000665B1; + productRefGroup = CAA1CAF2296DEE78000665B1 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + CAA1CAF0296DEE78000665B1 /* Integration */, + CAA1CB0A296DEE79000665B1 /* IntegrationUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + CAA1CAEF296DEE78000665B1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CAA1CAFC296DEE79000665B1 /* Preview Assets.xcassets in Resources */, + CAA1CAF9296DEE79000665B1 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CAA1CB09296DEE79000665B1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + CAA1CAED296DEE78000665B1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CAA1CB1F296DEEAC000665B1 /* ForEachBindingTestCase.swift in Sources */, + CA595273296DF46D00B5B695 /* NavigationStackBindingTestCase.swift in Sources */, + CAA1CAF5296DEE78000665B1 /* IntegrationApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CAA1CB07296DEE79000665B1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CA595275296DF55A00B5B695 /* NavigationStackBindingTests.swift in Sources */, + CAA1CB10296DEE79000665B1 /* ForEachBindingTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + CA59527A296DF6D000B5B695 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = CA595279296DF6D000B5B695 /* ComposableArchitecture */; + }; + CAA1CB0D296DEE79000665B1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CAA1CAF0296DEE78000665B1 /* Integration */; + targetProxy = CAA1CB0C296DEE79000665B1 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + CAA1CB13296DEE79000665B1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + CAA1CB14296DEE79000665B1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + CAA1CB16296DEE79000665B1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Integration/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Integration; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CAA1CB17296DEE79000665B1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Integration/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Integration; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + CAA1CB1C296DEE79000665B1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.IntegrationUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Integration; + }; + name = Debug; + }; + CAA1CB1D296DEE79000665B1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.IntegrationUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Integration; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + CAA1CAEC296DEE78000665B1 /* Build configuration list for PBXProject "Integration" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CAA1CB13296DEE79000665B1 /* Debug */, + CAA1CB14296DEE79000665B1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CAA1CB15296DEE79000665B1 /* Build configuration list for PBXNativeTarget "Integration" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CAA1CB16296DEE79000665B1 /* Debug */, + CAA1CB17296DEE79000665B1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CAA1CB1B296DEE79000665B1 /* Build configuration list for PBXNativeTarget "IntegrationUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CAA1CB1C296DEE79000665B1 /* Debug */, + CAA1CB1D296DEE79000665B1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + CA595277296DF67E00B5B695 /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitecture; + }; + CA595279296DF6D000B5B695 /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitecture; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = CAA1CAE9296DEE78000665B1 /* Project object */; +} diff --git a/Examples/Integration/Integration.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/Integration/Integration.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..919434a6254f --- /dev/null +++ b/Examples/Integration/Integration.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/Integration/Integration.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/Integration/Integration.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/Examples/Integration/Integration.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/Integration/Integration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Integration/Integration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000000..8b39bacc9fdb --- /dev/null +++ b/Examples/Integration/Integration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,113 @@ +{ + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", + "version" : "0.9.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "fddd1c00396eed152c45a46bea9f47b98e59301d", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-benchmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/swift-benchmark", + "state" : { + "revision" : "8163295f6fe82356b0bcf8e1ab991645de17d096", + "version" : "0.1.2" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "c3a42e8d1a76ff557cf565ed6d8b0aee0e6e75af", + "version" : "0.11.0" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "20b25ca0dd88ebfb9111ec937814ddc5a8880172", + "version" : "0.2.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "ead7d30cc224c3642c150b546f4f1080d1c411a8", + "version" : "0.6.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "e9e82b5302025092ab8358e794f89a0f0397dd9d", + "version" : "0.1.2" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "fd34c544ad27f3ba6b19142b348005bfa85b6005", + "version" : "0.6.0" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation", + "state" : { + "revision" : "ddc01cdcddfd30ef7a966049b2e1d251e224ad93", + "version" : "0.5.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "a9daebf0bf65981fd159c885d504481a65a75f02", + "version" : "0.8.0" + } + } + ], + "version" : 2 +} diff --git a/Examples/Integration/Integration.xcodeproj/xcshareddata/xcschemes/Integration.xcscheme b/Examples/Integration/Integration.xcodeproj/xcshareddata/xcschemes/Integration.xcscheme new file mode 100644 index 000000000000..3ea457f5a629 --- /dev/null +++ b/Examples/Integration/Integration.xcodeproj/xcshareddata/xcschemes/Integration.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/Integration/Integration/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/Integration/Integration/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000000..eb8789700816 --- /dev/null +++ b/Examples/Integration/Integration/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Integration/Integration/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/Integration/Integration/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..13613e3ee1a9 --- /dev/null +++ b/Examples/Integration/Integration/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Integration/Integration/Assets.xcassets/Contents.json b/Examples/Integration/Integration/Assets.xcassets/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/Examples/Integration/Integration/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Integration/Integration/ForEachBindingTestCase.swift b/Examples/Integration/Integration/ForEachBindingTestCase.swift new file mode 100644 index 000000000000..c981daf356b9 --- /dev/null +++ b/Examples/Integration/Integration/ForEachBindingTestCase.swift @@ -0,0 +1,55 @@ +import ComposableArchitecture +import SwiftUI + +struct ForEachBindingTestCase: ReducerProtocol { + struct State: Equatable { + var values = ["A", "B", "C"] + } + enum Action { + case change(offset: Int, value: String) + case removeLast + } + + func reduce(into state: inout State, action: Action) -> EffectTask { + switch action { + case let .change(offset: offset, value: value): + state.values[offset] = value + return .none + + case .removeLast: + guard !state.values.isEmpty + else { return .none } + state.values.removeLast() + return .none + } + } +} + +struct ForEachBindingTestCaseView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + VStack { // ⚠️ Must use VStack, not List. + ForEach(Array(viewStore.values.enumerated()), id: \.offset) { offset, value in + HStack { // ⚠️ Must wrap in an HStack. + TextField( // ⚠️ Must use a TextField. + "\(value)", + text: viewStore.binding( + get: { $0.values[offset] }, + send: { .change(offset: offset, value: $0) } + ) + ) + } + } + } + .toolbar { + ToolbarItem { + Button("Remove last") { + viewStore.send(.removeLast) + } + } + } + } + } +} diff --git a/Examples/Integration/Integration/IntegrationApp.swift b/Examples/Integration/Integration/IntegrationApp.swift new file mode 100644 index 000000000000..f61bf8b7393a --- /dev/null +++ b/Examples/Integration/Integration/IntegrationApp.swift @@ -0,0 +1,36 @@ +import ComposableArchitecture +import SwiftUI + +@main +struct IntegrationApp: App { + @State var isNavigationStackBindingTestCasePresented = false + + var body: some Scene { + WindowGroup { + NavigationStack { + List { + NavigationLink("ForEachBindingTestCase") { + ForEachBindingTestCaseView( + store: Store( + initialState: ForEachBindingTestCase.State(), + reducer: ForEachBindingTestCase() + ) + ) + } + + Button("NavigationStackBindingTestCase") { + self.isNavigationStackBindingTestCasePresented = true + } + .sheet(isPresented: self.$isNavigationStackBindingTestCasePresented) { + NavigationStackBindingTestCaseView( + store: Store( + initialState: NavigationStackBindingTestCase.State(), + reducer: NavigationStackBindingTestCase() + ) + ) + } + } + } + } + } +} diff --git a/Examples/Integration/Integration/NavigationStackBindingTestCase.swift b/Examples/Integration/Integration/NavigationStackBindingTestCase.swift new file mode 100644 index 000000000000..e1e99cf6a0e0 --- /dev/null +++ b/Examples/Integration/Integration/NavigationStackBindingTestCase.swift @@ -0,0 +1,55 @@ +import ComposableArchitecture +import SwiftUI + +struct NavigationStackBindingTestCase: ReducerProtocol { + struct State: Equatable { + var path: [Destination] = [] + enum Destination: Equatable { + case child + } + } + enum Action: Equatable, Sendable { + case goToChild + case navigationPathChanged([State.Destination]) + } + + func reduce(into state: inout State, action: Action) -> EffectTask { + switch action { + case .goToChild: + state.path.append(.child) + return .none + case let .navigationPathChanged(path): + state.path = path + return .none + } + } +} + +struct NavigationStackBindingTestCaseView: View { + let store: StoreOf + + var body: some View { + WithViewStore(store) { viewStore in + NavigationStack( + path: viewStore.binding( + get: \.path, + send: NavigationStackBindingTestCase.Action.navigationPathChanged + ) + ) { + VStack { + Text("Root") + Button("Go to child") { + viewStore.send(.goToChild) + } + } + .navigationDestination( + for: NavigationStackBindingTestCase.State.Destination.self + ) { destination in + switch destination { + case .child: Text("Child") + } + } + } + } + } +} diff --git a/Examples/Integration/Integration/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/Integration/Integration/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/Examples/Integration/Integration/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Integration/IntegrationUITests/ForEachBindingTests.swift b/Examples/Integration/IntegrationUITests/ForEachBindingTests.swift new file mode 100644 index 000000000000..4ac9c615ad30 --- /dev/null +++ b/Examples/Integration/IntegrationUITests/ForEachBindingTests.swift @@ -0,0 +1,19 @@ +import XCTest + +@MainActor +final class ForEachBindingTests: XCTestCase { + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testExample() async throws { + let app = XCUIApplication() + app.launch() + + app.collectionViews.buttons["ForEachBindingTestCase"].tap() + app.buttons["Remove last"].tap() + + XCTAssertFalse(app.textFields["C"].exists) + } +} diff --git a/Examples/Integration/IntegrationUITests/NavigationStackBindingTests.swift b/Examples/Integration/IntegrationUITests/NavigationStackBindingTests.swift new file mode 100644 index 000000000000..116ec13ae8db --- /dev/null +++ b/Examples/Integration/IntegrationUITests/NavigationStackBindingTests.swift @@ -0,0 +1,17 @@ +import XCTest + +@MainActor +final class NavigationStackBindingTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testExample() async throws { + let app = XCUIApplication() + app.launch() + app.collectionViews.buttons["NavigationStackBindingTestCase"].tap() + app.buttons["Go to child"].tap() + app.buttons["Back"].tap() + XCTAssertTrue(app.buttons["Go to child"].exists) + } +} diff --git a/Makefile b/Makefile index adaec430c0aa..6a7f50ebf052 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,7 @@ default: test-all test-all: test-examples CONFIG=debug test-library - CONFIG=release test-library - CONFIG=debug test-library - CONFIG=release test-library + CONFIG=release test-library test-library: for platform in "$(PLATFORM_IOS)" "$(PLATFORM_MACOS)" "$(PLATFORM_MAC_CATALYST)" "$(PLATFORM_TVOS)" "$(PLATFORM_WATCHOS)"; do \ @@ -42,7 +40,7 @@ test-docs: && exit 1) test-examples: - for scheme in "CaseStudies (SwiftUI)" "CaseStudies (UIKit)" Search SpeechRecognition TicTacToe Todos VoiceMemos; do \ + for scheme in "CaseStudies (SwiftUI)" "CaseStudies (UIKit)" Integration Search SpeechRecognition TicTacToe Todos VoiceMemos; do \ xcodebuild test \ -scheme "$$scheme" \ -destination platform="$(PLATFORM_IOS)"; \ From 09271d56f48bfdaa6c7c598b73eedc5b8f49a75d Mon Sep 17 00:00:00 2001 From: stephencelis Date: Tue, 10 Jan 2023 22:56:44 +0000 Subject: [PATCH 11/38] Run swift-format --- .../Integration/Integration/ForEachBindingTestCase.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Examples/Integration/Integration/ForEachBindingTestCase.swift b/Examples/Integration/Integration/ForEachBindingTestCase.swift index c981daf356b9..8e29002c435a 100644 --- a/Examples/Integration/Integration/ForEachBindingTestCase.swift +++ b/Examples/Integration/Integration/ForEachBindingTestCase.swift @@ -30,10 +30,10 @@ struct ForEachBindingTestCaseView: View { var body: some View { WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { // ⚠️ Must use VStack, not List. + VStack { // ⚠️ Must use VStack, not List. ForEach(Array(viewStore.values.enumerated()), id: \.offset) { offset, value in - HStack { // ⚠️ Must wrap in an HStack. - TextField( // ⚠️ Must use a TextField. + HStack { // ⚠️ Must wrap in an HStack. + TextField( // ⚠️ Must use a TextField. "\(value)", text: viewStore.binding( get: { $0.values[offset] }, From 0c99258494bf3fee1bca2c096a09bbbe01ed333d Mon Sep 17 00:00:00 2001 From: Kenta Aikawa Date: Thu, 12 Jan 2023 01:33:45 +0900 Subject: [PATCH 12/38] Add deprecated (#1822) --- Sources/ComposableArchitecture/Effect.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index 63506f98a3e5..5a965a9a5e80 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -663,6 +663,7 @@ extension EffectPublisher { @available( *, + deprecated, message: """ 'Effect' has been deprecated in favor of 'EffectTask' when 'Failure == Never', or 'EffectPublisher' in general. From db39334dcc593f613b63a5a84d80d70f0063f388 Mon Sep 17 00:00:00 2001 From: Thomas Grapperon <35562418+tgrapperon@users.noreply.github.com> Date: Wed, 11 Jan 2023 18:49:25 -0300 Subject: [PATCH 13/38] Add a UI test for escaped `ViewStore` from `WithViewStore`, and a `Binding` animations test bench (#1819) * Add a UITest for escaped `ViewStore` from `WithViewStore` * Add `BindingsAnimationsTestBench` * Cleanup --- .../Integration.xcodeproj/project.pbxproj | 14 +- .../BindingsAnimationsTestBench.swift | 194 ++++++++++++++++++ .../EscapedWithViewStoreTestCase.swift | 47 +++++ .../Integration/IntegrationApp.swift | 17 ++ .../EscapedWithViewStoreTests.swift | 41 ++++ 5 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 Examples/Integration/Integration/BindingsAnimationsTestBench.swift create mode 100644 Examples/Integration/Integration/EscapedWithViewStoreTestCase.swift create mode 100644 Examples/Integration/IntegrationUITests/EscapedWithViewStoreTests.swift diff --git a/Examples/Integration/Integration.xcodeproj/project.pbxproj b/Examples/Integration/Integration.xcodeproj/project.pbxproj index d7d2e1112d55..b392af8b7513 100644 --- a/Examples/Integration/Integration.xcodeproj/project.pbxproj +++ b/Examples/Integration/Integration.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ CAA1CAFC296DEE79000665B1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAA1CAFB296DEE79000665B1 /* Preview Assets.xcassets */; }; CAA1CB10296DEE79000665B1 /* ForEachBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB0F296DEE79000665B1 /* ForEachBindingTests.swift */; }; CAA1CB1F296DEEAC000665B1 /* ForEachBindingTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB1E296DEEAC000665B1 /* ForEachBindingTestCase.swift */; }; + E9919D3E296E28C800C8716B /* EscapedWithViewStoreTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9919D3D296E28C800C8716B /* EscapedWithViewStoreTestCase.swift */; }; + E9919D40296E3EF400C8716B /* EscapedWithViewStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9919D3F296E3EF400C8716B /* EscapedWithViewStoreTests.swift */; }; + E9919D42296E47A400C8716B /* BindingsAnimationsTestBench.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9919D41296E47A400C8716B /* BindingsAnimationsTestBench.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -38,6 +41,9 @@ CAA1CB0B296DEE79000665B1 /* IntegrationUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAA1CB0F296DEE79000665B1 /* ForEachBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForEachBindingTests.swift; sourceTree = ""; }; CAA1CB1E296DEEAC000665B1 /* ForEachBindingTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForEachBindingTestCase.swift; sourceTree = ""; }; + E9919D3D296E28C800C8716B /* EscapedWithViewStoreTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EscapedWithViewStoreTestCase.swift; sourceTree = ""; }; + E9919D3F296E3EF400C8716B /* EscapedWithViewStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EscapedWithViewStoreTests.swift; sourceTree = ""; }; + E9919D41296E47A400C8716B /* BindingsAnimationsTestBench.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BindingsAnimationsTestBench.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -82,9 +88,11 @@ CAA1CAF3296DEE78000665B1 /* Integration */ = { isa = PBXGroup; children = ( + E9919D41296E47A400C8716B /* BindingsAnimationsTestBench.swift */, + E9919D3D296E28C800C8716B /* EscapedWithViewStoreTestCase.swift */, CAA1CB1E296DEEAC000665B1 /* ForEachBindingTestCase.swift */, - CAA1CAF4296DEE78000665B1 /* IntegrationApp.swift */, CA595272296DF46D00B5B695 /* NavigationStackBindingTestCase.swift */, + CAA1CAF4296DEE78000665B1 /* IntegrationApp.swift */, CAA1CAF8296DEE79000665B1 /* Assets.xcassets */, CAA1CAFA296DEE79000665B1 /* Preview Content */, ); @@ -102,6 +110,7 @@ CAA1CB0E296DEE79000665B1 /* IntegrationUITests */ = { isa = PBXGroup; children = ( + E9919D3F296E3EF400C8716B /* EscapedWithViewStoreTests.swift */, CAA1CB0F296DEE79000665B1 /* ForEachBindingTests.swift */, CA595274296DF55A00B5B695 /* NavigationStackBindingTests.swift */, ); @@ -220,7 +229,9 @@ buildActionMask = 2147483647; files = ( CAA1CB1F296DEEAC000665B1 /* ForEachBindingTestCase.swift in Sources */, + E9919D42296E47A400C8716B /* BindingsAnimationsTestBench.swift in Sources */, CA595273296DF46D00B5B695 /* NavigationStackBindingTestCase.swift in Sources */, + E9919D3E296E28C800C8716B /* EscapedWithViewStoreTestCase.swift in Sources */, CAA1CAF5296DEE78000665B1 /* IntegrationApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -229,6 +240,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E9919D40296E3EF400C8716B /* EscapedWithViewStoreTests.swift in Sources */, CA595275296DF55A00B5B695 /* NavigationStackBindingTests.swift in Sources */, CAA1CB10296DEE79000665B1 /* ForEachBindingTests.swift in Sources */, ); diff --git a/Examples/Integration/Integration/BindingsAnimationsTestBench.swift b/Examples/Integration/Integration/BindingsAnimationsTestBench.swift new file mode 100644 index 000000000000..2ab2270229c2 --- /dev/null +++ b/Examples/Integration/Integration/BindingsAnimationsTestBench.swift @@ -0,0 +1,194 @@ +import ComposableArchitecture +import SwiftUI + +struct BindingsAnimations: ReducerProtocol { + func reduce(into state: inout Bool, action: Void) -> EffectTask { + state.toggle() + return .none + } +} + +final class VanillaModel: ObservableObject { + @Published var flag = false +} + +let mediumAnimation = Animation.linear(duration: 0.7) +let fastAnimation = Animation.linear(duration: 0.2) + +struct BindingsAnimationsTestBench: View { + let viewStore: ViewStoreOf + let vanillaModel = VanillaModel() + + init(store: StoreOf) { + self.viewStore = ViewStore(store, observe: { $0 }) + } + + var body: some View { + List { + Section { + SideBySide { + AnimatedWithObservation.ObservedObjectBinding() + } viewStoreView: { + AnimatedWithObservation.ViewStoreBinding() + } + } header: { + Text("Animated with observation.") + } footer: { + Text("Should animate with the \"medium\" animation.") + } + + Section { + SideBySide { + AnimatedFromBinding.ObservedObjectBinding() + } viewStoreView: { + AnimatedFromBinding.ViewStoreBinding() + } + } header: { + Text("Animated from binding.") + } footer: { + Text("Should animate with the \"fast\" animation.") + } + + Section { + SideBySide { + AnimatedFromBindingWithObservation.ObservedObjectBinding() + } viewStoreView: { + AnimatedFromBindingWithObservation.ViewStoreBinding() + } + } header: { + Text("Animated from binding with observation.") + } footer: { + Text("Should animate with the \"medium\" animation.") + } + } + .headerProminence(.increased) + .environmentObject(viewStore) + .environmentObject(vanillaModel) + } +} + +struct SideBySide: View { + let observedObjectView: ObservedObjectView + let viewStoreView: ViewStoreView + init( + @ViewBuilder observedObjectView: () -> ObservedObjectView, + @ViewBuilder viewStoreView: () -> ViewStoreView + ) { + self.observedObjectView = observedObjectView() + self.viewStoreView = viewStoreView() + } + var body: some View { + Grid { + GridRow { + observedObjectView + .frame(width: 100, height: 100) + viewStoreView + .frame(width: 100, height: 100) + } + .labelsHidden() + GridRow { + Text("@ObservedObject") + .fixedSize() + Text("ViewStore") + } + .font(.footnote.bold()) + .monospaced() + } + .frame(maxWidth: .infinity) + } +} + +struct ContentView: View { + @Binding var flag: Bool + + var body: some View { + ZStack { + Circle() + .fill(.red.opacity(0.25)) + Circle() + .strokeBorder(.red.opacity(0.5), lineWidth: 2) + } + .frame(width: flag ? 100 : 75) + } +} + +struct AnimatedWithObservation { + struct ObservedObjectBinding: View { + @EnvironmentObject var vanillaModel: VanillaModel + var body: some View { + ZStack { + ContentView(flag: $vanillaModel.flag) + .animation(mediumAnimation, value: vanillaModel.flag) + Toggle("", isOn: $vanillaModel.flag) + } + } + } + + struct ViewStoreBinding: View { + @EnvironmentObject var viewStore: ViewStoreOf + var body: some View { + ZStack { + ContentView(flag: viewStore.binding(send: ())) + .animation(mediumAnimation, value: viewStore.state) + Toggle("", isOn: viewStore.binding(send: ())) + } + } + } +} + +struct AnimatedFromBinding { + struct ObservedObjectBinding: View { + @EnvironmentObject var vanillaModel: VanillaModel + var body: some View { + ZStack { + ContentView(flag: $vanillaModel.flag) + Toggle("", isOn: $vanillaModel.flag.animation(fastAnimation)) + } + } + } + + struct ViewStoreBinding: View { + @EnvironmentObject var viewStore: ViewStoreOf + var body: some View { + ZStack { + ContentView(flag: viewStore.binding(send: ())) + Toggle("", isOn: viewStore.binding(send: ()).animation(fastAnimation)) + } + } + } +} + +struct AnimatedFromBindingWithObservation { + struct ObservedObjectBinding: View { + @EnvironmentObject var vanillaModel: VanillaModel + var body: some View { + ZStack { + ContentView(flag: $vanillaModel.flag) + .animation(mediumAnimation, value: vanillaModel.flag) + Toggle("", isOn: $vanillaModel.flag.animation(fastAnimation)) + } + } + } + + struct ViewStoreBinding: View { + @EnvironmentObject var viewStore: ViewStoreOf + var body: some View { + ZStack { + ContentView(flag: viewStore.binding(send: ())) + .animation(mediumAnimation, value: viewStore.state) + Toggle("", isOn: viewStore.binding(send: ()).animation(fastAnimation)) + } + } + } +} + +struct BindingsAnimationsTestBench_Previews: PreviewProvider { + static var previews: some View { + BindingsAnimationsTestBench( + store: .init( + initialState: false, + reducer: BindingsAnimations() + ) + ) + } +} diff --git a/Examples/Integration/Integration/EscapedWithViewStoreTestCase.swift b/Examples/Integration/Integration/EscapedWithViewStoreTestCase.swift new file mode 100644 index 000000000000..bb8b3f8cf42c --- /dev/null +++ b/Examples/Integration/Integration/EscapedWithViewStoreTestCase.swift @@ -0,0 +1,47 @@ +import ComposableArchitecture +import SwiftUI + +struct EscapedWithViewStoreTestCase: ReducerProtocol { + enum Action: Equatable, Sendable { + case incr + case decr + } + + func reduce(into state: inout Int, action: Action) -> EffectTask { + switch action { + case .incr: + state += 1 + return .none + case .decr: + state -= 1 + return .none + } + } +} + +struct EscapedWithViewStoreTestCaseView: View { + let store: StoreOf + + var body: some View { + VStack { + WithViewStore(store, observe: { $0 }) { viewStore in + GeometryReader { proxy in + Text("\(viewStore.state)") + .accessibilityValue("\(viewStore.state)") + .accessibilityLabel("EscapedLabel") + } + Button("Button", action: { viewStore.send(.incr) }) + Text("\(viewStore.state)") + .accessibilityValue("\(viewStore.state)") + .accessibilityLabel("Label") + Stepper { + Text("Stepper") + } onIncrement: { + viewStore.send(.incr) + } onDecrement: { + viewStore.send(.decr) + } + } + } + } +} diff --git a/Examples/Integration/Integration/IntegrationApp.swift b/Examples/Integration/Integration/IntegrationApp.swift index f61bf8b7393a..bdb037ce6118 100644 --- a/Examples/Integration/Integration/IntegrationApp.swift +++ b/Examples/Integration/Integration/IntegrationApp.swift @@ -9,6 +9,14 @@ struct IntegrationApp: App { WindowGroup { NavigationStack { List { + NavigationLink("EscapedWithViewStoreTestCase") { + EscapedWithViewStoreTestCaseView( + store: Store( + initialState: 10, + reducer: EscapedWithViewStoreTestCase() + ) + ) + } NavigationLink("ForEachBindingTestCase") { ForEachBindingTestCaseView( store: Store( @@ -29,6 +37,15 @@ struct IntegrationApp: App { ) ) } + + NavigationLink("Binding Animations Test Bench") { + BindingsAnimationsTestBench( + store: Store( + initialState: false, + reducer: BindingsAnimations() + ) + ) + } } } } diff --git a/Examples/Integration/IntegrationUITests/EscapedWithViewStoreTests.swift b/Examples/Integration/IntegrationUITests/EscapedWithViewStoreTests.swift new file mode 100644 index 000000000000..d06601549c31 --- /dev/null +++ b/Examples/Integration/IntegrationUITests/EscapedWithViewStoreTests.swift @@ -0,0 +1,41 @@ +import XCTest + +@MainActor +final class EscapedWithViewStoreTests: XCTestCase { + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testExample() async throws { + let app = XCUIApplication() + app.launch() + + app.collectionViews.buttons["EscapedWithViewStoreTestCase"].tap() + + XCTAssertEqual(app.staticTexts["Label"].value as? String, "10") + XCTAssertEqual(app.staticTexts["EscapedLabel"].value as? String, "10") + + app.buttons["Button"].tap() + + XCTAssertEqual(app.staticTexts["Label"].value as? String, "11") + XCTAssertEqual(app.staticTexts["EscapedLabel"].value as? String, "11") + + let stepper = app.steppers["Stepper"] + + stepper.buttons["Increment"].tap() + stepper.buttons["Increment"].tap() + stepper.buttons["Increment"].tap() + stepper.buttons["Increment"].tap() + + XCTAssertEqual(app.staticTexts["Label"].value as? String, "15") + XCTAssertEqual(app.staticTexts["EscapedLabel"].value as? String, "15") + + stepper.buttons["Decrement"].tap() + stepper.buttons["Decrement"].tap() + stepper.buttons["Decrement"].tap() + + XCTAssertEqual(app.staticTexts["Label"].value as? String, "12") + XCTAssertEqual(app.staticTexts["EscapedLabel"].value as? String, "12") + } +} From 9e83d195fbc6c3a58a35fb11de01c6a2947b4935 Mon Sep 17 00:00:00 2001 From: mbrandonw Date: Wed, 11 Jan 2023 22:02:42 +0000 Subject: [PATCH 14/38] Run swift-format --- Examples/Integration/Integration/IntegrationApp.swift | 2 +- .../IntegrationUITests/EscapedWithViewStoreTests.swift | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Examples/Integration/Integration/IntegrationApp.swift b/Examples/Integration/Integration/IntegrationApp.swift index bdb037ce6118..794b0f72d19f 100644 --- a/Examples/Integration/Integration/IntegrationApp.swift +++ b/Examples/Integration/Integration/IntegrationApp.swift @@ -37,7 +37,7 @@ struct IntegrationApp: App { ) ) } - + NavigationLink("Binding Animations Test Bench") { BindingsAnimationsTestBench( store: Store( diff --git a/Examples/Integration/IntegrationUITests/EscapedWithViewStoreTests.swift b/Examples/Integration/IntegrationUITests/EscapedWithViewStoreTests.swift index d06601549c31..189ec6524016 100644 --- a/Examples/Integration/IntegrationUITests/EscapedWithViewStoreTests.swift +++ b/Examples/Integration/IntegrationUITests/EscapedWithViewStoreTests.swift @@ -17,12 +17,12 @@ final class EscapedWithViewStoreTests: XCTestCase { XCTAssertEqual(app.staticTexts["EscapedLabel"].value as? String, "10") app.buttons["Button"].tap() - + XCTAssertEqual(app.staticTexts["Label"].value as? String, "11") XCTAssertEqual(app.staticTexts["EscapedLabel"].value as? String, "11") - + let stepper = app.steppers["Stepper"] - + stepper.buttons["Increment"].tap() stepper.buttons["Increment"].tap() stepper.buttons["Increment"].tap() @@ -30,11 +30,11 @@ final class EscapedWithViewStoreTests: XCTestCase { XCTAssertEqual(app.staticTexts["Label"].value as? String, "15") XCTAssertEqual(app.staticTexts["EscapedLabel"].value as? String, "15") - + stepper.buttons["Decrement"].tap() stepper.buttons["Decrement"].tap() stepper.buttons["Decrement"].tap() - + XCTAssertEqual(app.staticTexts["Label"].value as? String, "12") XCTAssertEqual(app.staticTexts["EscapedLabel"].value as? String, "12") } From b8f79b3318a0c1ca415781edca1d443d6c5d66f4 Mon Sep 17 00:00:00 2001 From: Kenta Aikawa Date: Fri, 13 Jan 2023 01:23:39 +0900 Subject: [PATCH 15/38] Explicitly depend on OrderedCollections (#1828) --- Package.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Package.swift b/Package.swift index d6ca572cf2c2..2b6faad3d695 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,7 @@ let package = Package( .package(url: "https://github.com/google/swift-benchmark", from: "0.1.0"), .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.8.0"), .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.10.0"), + .package(url: "https://github.com/apple/swift-collections", from: "1.0.2"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.6.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.1.2"), .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "0.4.1"), @@ -36,6 +37,7 @@ let package = Package( .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "IdentifiedCollections", package: "swift-identified-collections"), + .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "_SwiftUINavigationState", package: "swiftui-navigation"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), ] From a75e9dde7d4b4a43f0b3ea3742e45d704f412c42 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Thu, 12 Jan 2023 10:25:41 -0800 Subject: [PATCH 16/38] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4742ecb51126..4c0622ffefce 100644 --- a/README.md +++ b/README.md @@ -547,13 +547,14 @@ advanced usages. The documentation for releases and `main` are available here: * [`main`](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture) -* [0.48.0](https://pointfreeco.github.io/swift-composable-architecture/0.48.0/documentation/composablearchitecture/) +* [0.49.0](https://pointfreeco.github.io/swift-composable-architecture/0.49.0/documentation/composablearchitecture/)
Other versions + * [0.48.0](https://pointfreeco.github.io/swift-composable-architecture/0.48.0/documentation/composablearchitecture/) * [0.47.0](https://pointfreeco.github.io/swift-composable-architecture/0.47.0/documentation/composablearchitecture/) * [0.46.0](https://pointfreeco.github.io/swift-composable-architecture/0.46.0/documentation/composablearchitecture/) * [0.45.0](https://pointfreeco.github.io/swift-composable-architecture/0.45.0/documentation/composablearchitecture/) From 888af2fafc78c81b86d1da436142fa814cbd9c98 Mon Sep 17 00:00:00 2001 From: Jon Shier Date: Sat, 14 Jan 2023 15:03:45 -0500 Subject: [PATCH 17/38] Conditionally conform BindableState to Sendable. (#1834) --- Sources/ComposableArchitecture/SwiftUI/Binding.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index 871dc2660132..a56c59eb9ed6 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -91,6 +91,8 @@ extension BindableState: CustomDebugStringConvertible where Value: CustomDebugSt } } +extension BindableState: Sendable where Value: Sendable {} + /// An action type that exposes a `binding` case that holds a ``BindingAction``. /// /// Used in conjunction with ``BindableState`` to safely eliminate the boilerplate typically From 3810aee31ba8c088bae6c911832cf1b9801248e1 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 15 Jan 2023 13:16:52 -0800 Subject: [PATCH 18/38] Fix typo in docs. --- .../Articles/MigratingToTheReducerProtocol.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md index 2611edae556e..0c82be5e930c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md @@ -251,10 +251,10 @@ struct AppReducer: ReducerProtocol { Scope(state: \.tabA, action: /Action.tabA) { TabA() } - Scope(state: \.tabB, action: /Action.tabC) { + Scope(state: \.tabB, action: /Action.tabB) { TabB() } - Scope(state: \.tabB, action: /Action.tabC) { + Scope(state: \.tabC, action: /Action.tabC) { TabC() } } From eb5494051afd172330e8576656cd3d15e83f546c Mon Sep 17 00:00:00 2001 From: brenno <37243584+brennobemoura@users.noreply.github.com> Date: Tue, 17 Jan 2023 12:40:51 -0300 Subject: [PATCH 19/38] fixed typo at getting started (#1843) Co-authored-by: brennomoura --- .../Documentation.docc/Articles/GettingStarted.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md index 68fbeee8ef3f..1850051331bd 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md @@ -331,7 +331,7 @@ Then we can use it in the `reduce` implementation: ```swift case .numberFactButtonTapped: return .task { [count = state.count] in - await .numberFactResponse(TaskResult { try wait self.numberFact(count) }) + await .numberFactResponse(TaskResult { try await self.numberFact(count) }) } ``` From 39c62e65017b6eebfb746bad6a06aa6e05e9fa67 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 17 Jan 2023 10:15:19 -0800 Subject: [PATCH 20/38] Run flakey test on main serial executor --- .../EffectTests.swift | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/Tests/ComposableArchitectureTests/EffectTests.swift b/Tests/ComposableArchitectureTests/EffectTests.swift index 8da517904580..25be7edd6f75 100644 --- a/Tests/ComposableArchitectureTests/EffectTests.swift +++ b/Tests/ComposableArchitectureTests/EffectTests.swift @@ -107,30 +107,32 @@ final class EffectTests: XCTestCase { #if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) func testMerge() async { if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { - let clock = TestClock() + await _withMainSerialExecutor { + let clock = TestClock() - let effect = EffectPublisher.merge( - (1...3).map { count in - .task { - try await clock.sleep(for: .seconds(count)) - return count + let effect = EffectPublisher.merge( + (1...3).map { count in + .task { + try await clock.sleep(for: .seconds(count)) + return count + } } - } - ) + ) - var values: [Int] = [] - effect.sink(receiveValue: { values.append($0) }).store(in: &self.cancellables) + var values: [Int] = [] + effect.sink(receiveValue: { values.append($0) }).store(in: &self.cancellables) - XCTAssertEqual(values, []) + XCTAssertEqual(values, []) - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values, [1]) + await clock.advance(by: .seconds(1)) + XCTAssertEqual(values, [1]) - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values, [1, 2]) + await clock.advance(by: .seconds(1)) + XCTAssertEqual(values, [1, 2]) - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values, [1, 2, 3]) + await clock.advance(by: .seconds(1)) + XCTAssertEqual(values, [1, 2, 3]) + } } } #endif From 9e5221a88ebad8d44aeb95643a58264fc13417b5 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 17 Jan 2023 10:17:24 -0800 Subject: [PATCH 21/38] Fix EffectTask deprecation --- Sources/ComposableArchitecture/Effect.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index 5a965a9a5e80..13a783da711e 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -102,17 +102,17 @@ extension EffectPublisher { /// the Combine interface to ``EffectPublisher`` is considered soft deprecated, and you should /// eventually port to Swift's native concurrency tools. /// -/// > Important: The publisher interface to ``EffectTask`` is considered deperecated, and you should -/// try converting any uses of that interface to Swift's native concurrency tools. +/// > Important: The publisher interface to ``EffectTask`` is considered deprecated, and you should +/// > try converting any uses of that interface to Swift's native concurrency tools. /// > /// > Also, ``Store`` is not thread safe, and so all effects must receive values on the same -/// thread. This is typically the main thread, **and** if the store is being used to drive UI then -/// it must receive values on the main thread. +/// > thread. This is typically the main thread, **and** if the store is being used to drive UI +/// > then it must receive values on the main thread. /// > /// > This is only an issue if using the Combine interface of ``EffectPublisher`` as mentioned -/// above. If you are using Swift's concurrency tools and the `.task`, `.run` and `.fireAndForget` -/// functions on ``EffectTask``, then threading is automatically handled for you. -public typealias EffectTask = Effect +/// > above. If you are using Swift's concurrency tools and the `.task`, `.run`, and +/// > `.fireAndForget` functions on ``EffectTask``, then threading is automatically handled for you. +public typealias EffectTask = EffectPublisher extension EffectPublisher where Failure == Never { /// Wraps an asynchronous unit of work in an effect. From 717796fb4cc05514df633216b8b1c8f4a788a35d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 17 Jan 2023 17:25:22 -0800 Subject: [PATCH 22/38] Revert #1802 (#1845) Fixes #1812. This regression was subtle, but it's better to retain the old crashing behavior than introduce this change since it's been with the library for a very long time, and we have workarounds like `ForEachStore`. We'll try to re-explore these subtle binding behaviors in the future. --- Sources/ComposableArchitecture/ViewStore.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index d206dc519de9..65504b750395 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -466,10 +466,8 @@ public final class ViewStore: ObservableObject { get: @escaping (ViewState) -> Value, send valueToAction: @escaping (Value) -> ViewAction ) -> Binding { - let base = ObservedObject(wrappedValue: self) + ObservedObject(wrappedValue: self) .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] - - return Binding(get: { base.wrappedValue }, set: { base.transaction($1).wrappedValue = $0 }) } /// Derives a binding from the store that prevents direct writes to state and instead sends From b4e6e83c982fd715fd9049de205c46f91501fa1d Mon Sep 17 00:00:00 2001 From: Andrey Plotnikov <36012972+drucelweisse@users.noreply.github.com> Date: Wed, 18 Jan 2023 06:05:29 +0300 Subject: [PATCH 23/38] Add support for SwiftUI withTransaction API (#1824) * Adds withTransaction extension * reduce code * fix typo * Adds transaction support to Send and ViewStore.send * Update Sources/ComposableArchitecture/Effect.swift * Update Sources/ComposableArchitecture/ViewStore.swift Co-authored-by: Stephen Celis --- Sources/ComposableArchitecture/Effect.swift | 17 ++++++--- .../Effects/Animation.swift | 36 ++++++++++++++----- .../ComposableArchitecture/ViewStore.swift | 14 +++++++- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index 13a783da711e..9ce9041c3dd2 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -375,11 +375,20 @@ public struct Send { /// - action: An action. /// - animation: An animation. public func callAsFunction(_ action: Action, animation: Animation?) { - guard !Task.isCancelled else { return } - withAnimation(animation) { - self(action) - } + callAsFunction(action, transaction: Transaction(animation: animation)) } + + /// Sends an action back into the system from an effect with transaction. + /// + /// - Parameters: + /// - action: An action. + /// - transaction: A transaction. + public func callAsFunction(_ action: Action, transaction: Transaction) { + guard !Task.isCancelled else { return } + withTransaction(transaction) { + self(action) + } + } } // MARK: - Composing Effects diff --git a/Sources/ComposableArchitecture/Effects/Animation.swift b/Sources/ComposableArchitecture/Effects/Animation.swift index a951318ef19a..d15978f1b4c4 100644 --- a/Sources/ComposableArchitecture/Effects/Animation.swift +++ b/Sources/ComposableArchitecture/Effects/Animation.swift @@ -15,13 +15,31 @@ extension EffectPublisher { /// - Parameter animation: An animation. /// - Returns: A publisher. public func animation(_ animation: Animation? = .default) -> Self { + self.transaction(Transaction(animation: animation)) + } + + /// Wraps the emission of each element with SwiftUI's `withTransaction`. + /// + /// ```swift + /// case .buttonTapped: + /// var transaction = Transaction(animation: .default) + /// transaction.disablesAnimations = true + /// return .task { + /// .activityResponse(await self.apiClient.fetchActivity()) + /// } + /// .transaction(transaction) + /// ``` + /// + /// - Parameter transaction: A transaction. + /// - Returns: A publisher. + public func transaction(_ transaction: Transaction) -> Self { switch self.operation { case .none: return .none case let .publisher(publisher): return Self( operation: .publisher( - AnimatedPublisher(upstream: publisher, animation: animation).eraseToAnyPublisher() + TransactionPublisher(upstream: publisher, transaction: transaction).eraseToAnyPublisher() ) ) case let .run(priority, operation): @@ -29,7 +47,7 @@ extension EffectPublisher { operation: .run(priority) { send in await operation( Send { value in - withAnimation(animation) { + withTransaction(transaction) { send(value) } } @@ -40,16 +58,16 @@ extension EffectPublisher { } } -private struct AnimatedPublisher: Publisher { +private struct TransactionPublisher: Publisher { typealias Output = Upstream.Output typealias Failure = Upstream.Failure var upstream: Upstream - var animation: Animation? + var transaction: Transaction func receive(subscriber: S) where S.Input == Output, S.Failure == Failure { - let conduit = Subscriber(downstream: subscriber, animation: self.animation) + let conduit = Subscriber(downstream: subscriber, transaction: self.transaction) self.upstream.receive(subscriber: conduit) } @@ -58,11 +76,11 @@ private struct AnimatedPublisher: Publisher { typealias Failure = Downstream.Failure let downstream: Downstream - let animation: Animation? + let transaction: Transaction - init(downstream: Downstream, animation: Animation?) { + init(downstream: Downstream, transaction: Transaction) { self.downstream = downstream - self.animation = animation + self.transaction = transaction } func receive(subscription: Subscription) { @@ -70,7 +88,7 @@ private struct AnimatedPublisher: Publisher { } func receive(_ input: Input) -> Subscribers.Demand { - withAnimation(self.animation) { + withTransaction(self.transaction) { self.downstream.receive(input) } } diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index 65504b750395..c277c6af9c17 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -290,7 +290,19 @@ public final class ViewStore: ObservableObject { /// - animation: An animation. @discardableResult public func send(_ action: ViewAction, animation: Animation?) -> ViewStoreTask { - withAnimation(animation) { + send(action, transaction: Transaction(animation: animation)) + } + + /// Sends an action to the store with a given transaction. + /// + /// See ``ViewStore/send(_:)`` for more info. + /// + /// - Parameters: + /// - action: An action. + /// - transaction: A transaction. + @discardableResult + public func send(_ action: ViewAction, transaction: Transaction) -> ViewStoreTask { + withTransaction(transaction) { self.send(action) } } From fd9ce8d14e17e6c0b7c285231e689a25fc3b6aa5 Mon Sep 17 00:00:00 2001 From: mbrandonw Date: Wed, 18 Jan 2023 04:07:46 +0000 Subject: [PATCH 24/38] Run swift-format --- Sources/ComposableArchitecture/Effect.swift | 22 +++++++++---------- .../Effects/Animation.swift | 2 +- .../ComposableArchitecture/ViewStore.swift | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index 9ce9041c3dd2..521ee50cb51a 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -377,18 +377,18 @@ public struct Send { public func callAsFunction(_ action: Action, animation: Animation?) { callAsFunction(action, transaction: Transaction(animation: animation)) } - - /// Sends an action back into the system from an effect with transaction. - /// - /// - Parameters: - /// - action: An action. - /// - transaction: A transaction. - public func callAsFunction(_ action: Action, transaction: Transaction) { - guard !Task.isCancelled else { return } - withTransaction(transaction) { - self(action) - } + + /// Sends an action back into the system from an effect with transaction. + /// + /// - Parameters: + /// - action: An action. + /// - transaction: A transaction. + public func callAsFunction(_ action: Action, transaction: Transaction) { + guard !Task.isCancelled else { return } + withTransaction(transaction) { + self(action) } + } } // MARK: - Composing Effects diff --git a/Sources/ComposableArchitecture/Effects/Animation.swift b/Sources/ComposableArchitecture/Effects/Animation.swift index d15978f1b4c4..f7f80866c661 100644 --- a/Sources/ComposableArchitecture/Effects/Animation.swift +++ b/Sources/ComposableArchitecture/Effects/Animation.swift @@ -17,7 +17,7 @@ extension EffectPublisher { public func animation(_ animation: Animation? = .default) -> Self { self.transaction(Transaction(animation: animation)) } - + /// Wraps the emission of each element with SwiftUI's `withTransaction`. /// /// ```swift diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index c277c6af9c17..dd713668c926 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -292,7 +292,7 @@ public final class ViewStore: ObservableObject { public func send(_ action: ViewAction, animation: Animation?) -> ViewStoreTask { send(action, transaction: Transaction(animation: animation)) } - + /// Sends an action to the store with a given transaction. /// /// See ``ViewStore/send(_:)`` for more info. From c5a7d1be9a95be974be8682a074d4ed6a6dde987 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 17 Jan 2023 20:08:52 -0800 Subject: [PATCH 25/38] Add `prepareDependencies` to `Store.init` (#1844) * Add `prepareDependencies` to `Store.init`:wq * wip * test and doc * update TTT previews to use new trailing closure style" * wip Co-authored-by: Brandon Williams --- .../01-GettingStarted-AnimationsTests.swift | 18 +-- .../02-Effects-BasicsTests.swift | 26 ++-- .../02-Effects-CancellationTests.swift | 32 ++--- .../02-Effects-LongLivingTests.swift | 6 +- .../02-Effects-RefreshableTests.swift | 31 +++-- .../02-Effects-TimersTests.swift | 9 +- .../02-Effects-WebSocketTests.swift | 78 +++++------ ...4-HigherOrderReducers-LifecycleTests.swift | 9 +- ...4-HigherOrderReducers-RecursionTests.swift | 6 +- ...ducers-ReusableOfflineDownloadsTests.swift | 24 ++-- .../tvOSCaseStudiesTests/FocusTests.swift | 6 +- Examples/Search/SearchTests/SearchTests.swift | 43 +++--- .../SpeechRecognitionTests.swift | 38 +++--- .../Sources/LoginSwiftUI/LoginView.swift | 15 +- .../TwoFactorSwiftUI/TwoFactorView.swift | 15 +- .../Tests/AppCoreTests/AppCoreTests.swift | 22 +-- .../Tests/LoginCoreTests/LoginCoreTests.swift | 30 ++-- .../LoginSwiftUITests/LoginSwiftUITests.swift | 30 ++-- .../TwoFactorCoreTests.swift | 16 +-- .../TwoFactorSwiftUITests.swift | 20 +-- Examples/Todos/TodosTests/TodosTests.swift | 32 ++--- .../VoiceMemosTests/VoiceMemosTests.swift | 128 +++++++++--------- README.md | 6 +- .../Articles/GettingStarted.md | 6 +- .../Articles/MigratingToTheReducerProtocol.md | 16 ++- .../Documentation.docc/Articles/Testing.md | 6 +- Sources/ComposableArchitecture/Effect.swift | 8 +- .../Effects/Publisher/Timer.swift | 6 +- Sources/ComposableArchitecture/Store.swift | 23 +++- .../ComposableArchitecture/TestStore.swift | 36 ++--- .../ComposableArchitectureTests.swift | 9 +- .../DebugTests.swift | 5 +- .../ReducerTests.swift | 9 +- .../StoreTests.swift | 20 +++ .../TestStoreNonExhaustiveTests.swift | 5 +- .../TestStoreTests.swift | 54 ++++++++ 36 files changed, 477 insertions(+), 366 deletions(-) diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AnimationsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AnimationsTests.swift index f78210b93ceb..c3ca4fddeb7c 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AnimationsTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AnimationsTests.swift @@ -7,13 +7,14 @@ import XCTest @MainActor final class AnimationTests: XCTestCase { func testRainbow() async { + let clock = TestClock() + let store = TestStore( initialState: Animations.State(), reducer: Animations() - ) - - let clock = TestClock() - store.dependencies.continuousClock = clock + ) { + $0.continuousClock = clock + } await store.send(.rainbowButtonTapped) await store.receive(.setColor(.red)) { @@ -59,13 +60,14 @@ final class AnimationTests: XCTestCase { } func testReset() async { + let clock = TestClock() + let store = TestStore( initialState: Animations.State(), reducer: Animations() - ) - - let clock = TestClock() - store.dependencies.continuousClock = clock + ) { + $0.continuousClock = clock + } await store.send(.rainbowButtonTapped) await store.receive(.setColor(.red)) { diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift index 661d32979609..b731c8418236 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift @@ -9,9 +9,9 @@ final class EffectsBasicsTests: XCTestCase { let store = TestStore( initialState: EffectsBasics.State(), reducer: EffectsBasics() - ) - - store.dependencies.continuousClock = ImmediateClock() + ) { + $0.continuousClock = ImmediateClock() + } await store.send(.incrementButtonTapped) { $0.count = 1 @@ -25,10 +25,10 @@ final class EffectsBasicsTests: XCTestCase { let store = TestStore( initialState: EffectsBasics.State(), reducer: EffectsBasics() - ) - - store.dependencies.factClient.fetch = { "\($0) is a good number Brent" } - store.dependencies.continuousClock = ImmediateClock() + ) { + $0.factClient.fetch = { "\($0) is a good number Brent" } + $0.continuousClock = ImmediateClock() + } await store.send(.incrementButtonTapped) { $0.count = 1 @@ -46,9 +46,9 @@ final class EffectsBasicsTests: XCTestCase { let store = TestStore( initialState: EffectsBasics.State(), reducer: EffectsBasics() - ) - - store.dependencies.continuousClock = ImmediateClock() + ) { + $0.continuousClock = ImmediateClock() + } await store.send(.decrementButtonTapped) { $0.count = -1 @@ -62,9 +62,9 @@ final class EffectsBasicsTests: XCTestCase { let store = TestStore( initialState: EffectsBasics.State(), reducer: EffectsBasics() - ) - - store.dependencies.continuousClock = TestClock() + ) { + $0.continuousClock = TestClock() + } await store.send(.decrementButtonTapped) { $0.count = -1 diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift index 6b94fb25085a..37e5e8fab434 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift @@ -9,9 +9,9 @@ final class EffectsCancellationTests: XCTestCase { let store = TestStore( initialState: EffectsCancellation.State(), reducer: EffectsCancellation() - ) - - store.dependencies.factClient.fetch = { "\($0) is a good number Brent" } + ) { + $0.factClient.fetch = { "\($0) is a good number Brent" } + } await store.send(.stepperChanged(1)) { $0.count = 1 @@ -33,9 +33,9 @@ final class EffectsCancellationTests: XCTestCase { let store = TestStore( initialState: EffectsCancellation.State(), reducer: EffectsCancellation() - ) - - store.dependencies.factClient.fetch = { _ in throw FactError() } + ) { + $0.factClient.fetch = { _ in throw FactError() } + } await store.send(.factButtonTapped) { $0.isFactRequestInFlight = true @@ -55,11 +55,11 @@ final class EffectsCancellationTests: XCTestCase { let store = TestStore( initialState: EffectsCancellation.State(), reducer: EffectsCancellation() - ) - - store.dependencies.factClient.fetch = { - try await Task.sleep(nanoseconds: NSEC_PER_SEC) - return "\($0) is a good number Brent" + ) { + $0.factClient.fetch = { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return "\($0) is a good number Brent" + } } await store.send(.factButtonTapped) { @@ -74,11 +74,11 @@ final class EffectsCancellationTests: XCTestCase { let store = TestStore( initialState: EffectsCancellation.State(), reducer: EffectsCancellation() - ) - - store.dependencies.factClient.fetch = { - try await Task.sleep(nanoseconds: NSEC_PER_SEC) - return "\($0) is a good number Brent" + ) { + $0.factClient.fetch = { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return "\($0) is a good number Brent" + } } await store.send(.factButtonTapped) { diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift index 773caf165c95..4abf6841000c 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift @@ -11,9 +11,9 @@ final class LongLivingEffectsTests: XCTestCase { let store = TestStore( initialState: LongLivingEffects.State(), reducer: LongLivingEffects() - ) - - store.dependencies.screenshots = { screenshots } + ) { + $0.screenshots = { screenshots } + } let task = await store.send(.task) diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-RefreshableTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-RefreshableTests.swift index a62a9d61fe61..25f34edaa7ba 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-RefreshableTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-RefreshableTests.swift @@ -9,10 +9,10 @@ final class RefreshableTests: XCTestCase { let store = TestStore( initialState: Refreshable.State(), reducer: Refreshable() - ) - - store.dependencies.factClient.fetch = { "\($0) is a good number." } - store.dependencies.continuousClock = ImmediateClock() + ) { + $0.factClient.fetch = { "\($0) is a good number." } + $0.continuousClock = ImmediateClock() + } await store.send(.incrementButtonTapped) { $0.count = 1 @@ -24,14 +24,15 @@ final class RefreshableTests: XCTestCase { } func testUnhappyPath() async { + struct FactError: Equatable, Error {} + let store = TestStore( initialState: Refreshable.State(), reducer: Refreshable() - ) - - struct FactError: Equatable, Error {} - store.dependencies.factClient.fetch = { _ in throw FactError() } - store.dependencies.continuousClock = ImmediateClock() + ) { + $0.factClient.fetch = { _ in throw FactError() } + $0.continuousClock = ImmediateClock() + } await store.send(.incrementButtonTapped) { $0.count = 1 @@ -44,13 +45,13 @@ final class RefreshableTests: XCTestCase { let store = TestStore( initialState: Refreshable.State(), reducer: Refreshable() - ) - - store.dependencies.factClient.fetch = { - try await Task.sleep(nanoseconds: NSEC_PER_SEC) - return "\($0) is a good number." + ) { + $0.factClient.fetch = { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return "\($0) is a good number." + } + $0.continuousClock = ImmediateClock() } - store.dependencies.continuousClock = ImmediateClock() await store.send(.incrementButtonTapped) { $0.count = 1 diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift index 8a500ce58613..d169dde5bfaa 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift @@ -6,13 +6,14 @@ import XCTest @MainActor final class TimersTests: XCTestCase { func testStart() async { + let clock = TestClock() + let store = TestStore( initialState: Timers.State(), reducer: Timers() - ) - - let clock = TestClock() - store.dependencies.continuousClock = clock + ) { + $0.continuousClock = clock + } await store.send(.toggleTimerButtonTapped) { $0.isTimerActive = true diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift index b8393ef832af..1c55bb598036 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift @@ -6,19 +6,19 @@ import XCTest @MainActor final class WebSocketTests: XCTestCase { func testWebSocketHappyPath() async { - let store = TestStore( - initialState: WebSocket.State(), - reducer: WebSocket() - ) - let actions = AsyncStream.streamWithContinuation() let messages = AsyncStream>.streamWithContinuation() - store.dependencies.continuousClock = ImmediateClock() - store.dependencies.webSocket.open = { _, _, _ in actions.stream } - store.dependencies.webSocket.receive = { _ in messages.stream } - store.dependencies.webSocket.send = { _, _ in } - store.dependencies.webSocket.sendPing = { _ in try await Task.never() } + let store = TestStore( + initialState: WebSocket.State(), + reducer: WebSocket() + ) { + $0.continuousClock = ImmediateClock() + $0.webSocket.open = { _, _, _ in actions.stream } + $0.webSocket.receive = { _ in messages.stream } + $0.webSocket.send = { _, _ in } + $0.webSocket.sendPing = { _ in try await Task.never() } + } // Connect to the socket await store.send(.connectButtonTapped) { @@ -58,22 +58,22 @@ final class WebSocketTests: XCTestCase { } func testWebSocketSendFailure() async { - let store = TestStore( - initialState: WebSocket.State(), - reducer: WebSocket() - ) - let actions = AsyncStream.streamWithContinuation() let messages = AsyncStream>.streamWithContinuation() - store.dependencies.continuousClock = ImmediateClock() - store.dependencies.webSocket.open = { _, _, _ in actions.stream } - store.dependencies.webSocket.receive = { _ in messages.stream } - store.dependencies.webSocket.send = { _, _ in - struct SendFailure: Error, Equatable {} - throw SendFailure() + let store = TestStore( + initialState: WebSocket.State(), + reducer: WebSocket() + ) { + $0.continuousClock = ImmediateClock() + $0.webSocket.open = { _, _, _ in actions.stream } + $0.webSocket.receive = { _ in messages.stream } + $0.webSocket.send = { _, _ in + struct SendFailure: Error, Equatable {} + throw SendFailure() + } + $0.webSocket.sendPing = { _ in try await Task.never() } } - store.dependencies.webSocket.sendPing = { _ in try await Task.never() } // Connect to the socket await store.send(.connectButtonTapped) { @@ -105,19 +105,19 @@ final class WebSocketTests: XCTestCase { } func testWebSocketPings() async { - let store = TestStore( - initialState: WebSocket.State(), - reducer: WebSocket() - ) - let actions = AsyncStream.streamWithContinuation() let clock = TestClock() var pingsCount = 0 - store.dependencies.continuousClock = clock - store.dependencies.webSocket.open = { _, _, _ in actions.stream } - store.dependencies.webSocket.receive = { _ in try await Task.never() } - store.dependencies.webSocket.sendPing = { @MainActor _ in pingsCount += 1 } + let store = TestStore( + initialState: WebSocket.State(), + reducer: WebSocket() + ) { + $0.continuousClock = clock + $0.webSocket.open = { _, _, _ in actions.stream } + $0.webSocket.receive = { _ in try await Task.never() } + $0.webSocket.sendPing = { @MainActor _ in pingsCount += 1 } + } // Connect to the socket await store.send(.connectButtonTapped) { @@ -140,17 +140,17 @@ final class WebSocketTests: XCTestCase { } func testWebSocketConnectError() async { + let actions = AsyncStream.streamWithContinuation() + let store = TestStore( initialState: WebSocket.State(), reducer: WebSocket() - ) - - let actions = AsyncStream.streamWithContinuation() - - store.dependencies.continuousClock = ImmediateClock() - store.dependencies.webSocket.open = { _, _, _ in actions.stream } - store.dependencies.webSocket.receive = { _ in try await Task.never() } - store.dependencies.webSocket.sendPing = { _ in try await Task.never() } + ) { + $0.continuousClock = ImmediateClock() + $0.webSocket.open = { _, _, _ in actions.stream } + $0.webSocket.receive = { _ in try await Task.never() } + $0.webSocket.sendPing = { _ in try await Task.never() } + } // Attempt to connect to the socket await store.send(.connectButtonTapped) { diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-LifecycleTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-LifecycleTests.swift index 32a3311a32e1..0b006cf1e967 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-LifecycleTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-LifecycleTests.swift @@ -6,13 +6,14 @@ import XCTest @MainActor final class LifecycleTests: XCTestCase { func testLifecycle() async { + let clock = TestClock() + let store = TestStore( initialState: LifecycleDemo.State(), reducer: LifecycleDemo() - ) - - let clock = TestClock() - store.dependencies.continuousClock = clock + ) { + $0.continuousClock = clock + } await store.send(.toggleTimerButtonTapped) { $0.count = 0 diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-RecursionTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-RecursionTests.swift index e59b62801118..d61a2bf8a174 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-RecursionTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-RecursionTests.swift @@ -9,9 +9,9 @@ final class RecursionTests: XCTestCase { let store = TestStore( initialState: Nested.State(id: UUID()), reducer: Nested() - ) - - store.dependencies.uuid = .incrementing + ) { + $0.uuid = .incrementing + } let id0 = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! let id1 = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift index a021c125ffac..01073b06e7d4 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift @@ -15,9 +15,9 @@ final class ReusableComponentsDownloadComponentTests: XCTestCase { url: URL(string: "https://www.pointfree.co")! ), reducer: DownloadComponent() - ) - - store.dependencies.downloadClient.download = { _ in self.download.stream } + ) { + $0.downloadClient.download = { _ in self.download.stream } + } await store.send(.buttonTapped) { $0.mode = .startingToDownload @@ -43,9 +43,9 @@ final class ReusableComponentsDownloadComponentTests: XCTestCase { url: URL(string: "https://www.pointfree.co")! ), reducer: DownloadComponent() - ) - - store.dependencies.downloadClient.download = { _ in self.download.stream } + ) { + $0.downloadClient.download = { _ in self.download.stream } + } await store.send(.buttonTapped) { $0.mode = .startingToDownload @@ -83,9 +83,9 @@ final class ReusableComponentsDownloadComponentTests: XCTestCase { url: URL(string: "https://www.pointfree.co")! ), reducer: DownloadComponent() - ) - - store.dependencies.downloadClient.download = { _ in self.download.stream } + ) { + $0.downloadClient.download = { _ in self.download.stream } + } let task = await store.send(.buttonTapped) { $0.mode = .startingToDownload @@ -122,9 +122,9 @@ final class ReusableComponentsDownloadComponentTests: XCTestCase { url: URL(string: "https://www.pointfree.co")! ), reducer: DownloadComponent() - ) - - store.dependencies.downloadClient.download = { _ in self.download.stream } + ) { + $0.downloadClient.download = { _ in self.download.stream } + } await store.send(.buttonTapped) { $0.alert = AlertState { diff --git a/Examples/CaseStudies/tvOSCaseStudiesTests/FocusTests.swift b/Examples/CaseStudies/tvOSCaseStudiesTests/FocusTests.swift index 44056e331245..f104a91fe776 100644 --- a/Examples/CaseStudies/tvOSCaseStudiesTests/FocusTests.swift +++ b/Examples/CaseStudies/tvOSCaseStudiesTests/FocusTests.swift @@ -9,9 +9,9 @@ final class tvOSCaseStudiesTests: XCTestCase { let store = TestStore( initialState: Focus.State(currentFocus: 1), reducer: Focus() - ) - - store.dependencies.withRandomNumberGenerator = .init(LCRNG()) + ) { + $0.withRandomNumberGenerator = .init(LCRNG()) + } await store.send(.randomButtonClicked) await store.send(.randomButtonClicked) { diff --git a/Examples/Search/SearchTests/SearchTests.swift b/Examples/Search/SearchTests/SearchTests.swift index 418a031d0ac7..95fc932d5264 100644 --- a/Examples/Search/SearchTests/SearchTests.swift +++ b/Examples/Search/SearchTests/SearchTests.swift @@ -9,9 +9,9 @@ final class SearchTests: XCTestCase { let store = TestStore( initialState: Search.State(), reducer: Search() - ) - - store.dependencies.weatherClient.search = { _ in .mock } + ) { + $0.weatherClient.search = { _ in .mock } + } await store.send(.searchQueryChanged("S")) { $0.searchQuery = "S" @@ -30,9 +30,9 @@ final class SearchTests: XCTestCase { let store = TestStore( initialState: Search.State(), reducer: Search() - ) - - store.dependencies.weatherClient.search = { _ in throw SomethingWentWrong() } + ) { + $0.weatherClient.search = { _ in throw SomethingWentWrong() } + } await store.send(.searchQueryChanged("S")) { $0.searchQuery = "S" @@ -45,9 +45,9 @@ final class SearchTests: XCTestCase { let store = TestStore( initialState: Search.State(), reducer: Search() - ) - - store.dependencies.weatherClient.search = { _ in .mock } + ) { + $0.weatherClient.search = { _ in .mock } + } let searchQueryChanged = await store.send(.searchQueryChanged("S")) { $0.searchQuery = "S" @@ -73,9 +73,9 @@ final class SearchTests: XCTestCase { let store = TestStore( initialState: Search.State(results: results), reducer: Search() - ) - - store.dependencies.weatherClient.forecast = { _ in .mock } + ) { + $0.weatherClient.forecast = { _ in .mock } + } await store.send(.searchResultTapped(specialResult)) { $0.resultForecastRequestInFlight = specialResult @@ -123,15 +123,16 @@ final class SearchTests: XCTestCase { var results = GeocodingSearch.mock.results results.append(specialResult) + let clock = TestClock() + let store = TestStore( initialState: Search.State(results: results), reducer: Search() - ) - - let clock = TestClock() - store.dependencies.weatherClient.forecast = { _ in - try await clock.sleep(for: .seconds(0)) - return .mock + ) { + $0.weatherClient.forecast = { _ in + try await clock.sleep(for: .seconds(0)) + return .mock + } } await store.send(.searchResultTapped(results.first!)) { @@ -178,9 +179,9 @@ final class SearchTests: XCTestCase { let store = TestStore( initialState: Search.State(results: results), reducer: Search() - ) - - store.dependencies.weatherClient.forecast = { _ in throw SomethingWentWrong() } + ) { + $0.weatherClient.forecast = { _ in throw SomethingWentWrong() } + } await store.send(.searchResultTapped(results.first!)) { $0.resultForecastRequestInFlight = results.first! diff --git a/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift b/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift index 5731d645e847..b3001b3aa30d 100644 --- a/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift +++ b/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift @@ -11,9 +11,9 @@ final class SpeechRecognitionTests: XCTestCase { let store = TestStore( initialState: SpeechRecognition.State(), reducer: SpeechRecognition() - ) - - store.dependencies.speechClient.requestAuthorization = { .denied } + ) { + $0.speechClient.requestAuthorization = { .denied } + } await store.send(.recordButtonTapped) { $0.isRecording = true @@ -34,9 +34,9 @@ final class SpeechRecognitionTests: XCTestCase { let store = TestStore( initialState: SpeechRecognition.State(), reducer: SpeechRecognition() - ) - - store.dependencies.speechClient.requestAuthorization = { .restricted } + ) { + $0.speechClient.requestAuthorization = { .restricted } + } await store.send(.recordButtonTapped) { $0.isRecording = true @@ -51,11 +51,11 @@ final class SpeechRecognitionTests: XCTestCase { let store = TestStore( initialState: SpeechRecognition.State(), reducer: SpeechRecognition() - ) - - store.dependencies.speechClient.finishTask = { self.recognitionTask.continuation.finish() } - store.dependencies.speechClient.startTask = { _ in self.recognitionTask.stream } - store.dependencies.speechClient.requestAuthorization = { .authorized } + ) { + $0.speechClient.finishTask = { self.recognitionTask.continuation.finish() } + $0.speechClient.startTask = { _ in self.recognitionTask.stream } + $0.speechClient.requestAuthorization = { .authorized } + } let firstResult = SpeechRecognitionResult( bestTranscription: Transcription( @@ -95,10 +95,10 @@ final class SpeechRecognitionTests: XCTestCase { let store = TestStore( initialState: SpeechRecognition.State(), reducer: SpeechRecognition() - ) - - store.dependencies.speechClient.startTask = { _ in self.recognitionTask.stream } - store.dependencies.speechClient.requestAuthorization = { .authorized } + ) { + $0.speechClient.startTask = { _ in self.recognitionTask.stream } + $0.speechClient.requestAuthorization = { .authorized } + } await store.send(.recordButtonTapped) { $0.isRecording = true @@ -116,10 +116,10 @@ final class SpeechRecognitionTests: XCTestCase { let store = TestStore( initialState: SpeechRecognition.State(), reducer: SpeechRecognition() - ) - - store.dependencies.speechClient.startTask = { _ in self.recognitionTask.stream } - store.dependencies.speechClient.requestAuthorization = { .authorized } + ) { + $0.speechClient.startTask = { _ in self.recognitionTask.stream } + $0.speechClient.requestAuthorization = { .authorized } + } await store.send(.recordButtonTapped) { $0.isRecording = true diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift index 45f74b14940f..4bf23491b15a 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift @@ -125,13 +125,14 @@ struct LoginView_Previews: PreviewProvider { store: Store( initialState: Login.State(), reducer: Login() - .dependency(\.authenticationClient.login) { _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) - } - .dependency(\.authenticationClient.twoFactor) { _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) - } - ) + ) { + $0.authenticationClient.login = { _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } + $0.authenticationClient.twoFactor = { _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } + } ) } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift index 110704c2378d..ce91cc15b977 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift @@ -93,13 +93,14 @@ struct TwoFactorView_Previews: PreviewProvider { store: Store( initialState: TwoFactor.State(token: "deadbeef"), reducer: TwoFactor() - .dependency(\.authenticationClient.login) { _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) - } - .dependency(\.authenticationClient.twoFactor) { _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) - } - ) + ) { + $0.authenticationClient.login = { _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } + $0.authenticationClient.twoFactor = { _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } + } ) } } diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift index c786b47e68c6..e4ca35f2bafe 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift @@ -12,10 +12,10 @@ final class AppCoreTests: XCTestCase { let store = TestStore( initialState: TicTacToe.State(), reducer: TicTacToe() - ) - - store.dependencies.authenticationClient.login = { _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + ) { + $0.authenticationClient.login = { _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } } await store.send(.login(.emailChanged("blob@pointfree.co"))) { @@ -57,13 +57,13 @@ final class AppCoreTests: XCTestCase { let store = TestStore( initialState: TicTacToe.State(), reducer: TicTacToe() - ) - - store.dependencies.authenticationClient.login = { _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: true) - } - store.dependencies.authenticationClient.twoFactor = { _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + ) { + $0.authenticationClient.login = { _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: true) + } + $0.authenticationClient.twoFactor = { _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } } await store.send(.login(.emailChanged("blob@pointfree.co"))) { diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift index 76d9a1af266c..929c14dc76e9 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift @@ -10,13 +10,13 @@ final class LoginCoreTests: XCTestCase { let store = TestStore( initialState: Login.State(), reducer: Login() - ) - - store.dependencies.authenticationClient.login = { _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) - } - store.dependencies.authenticationClient.twoFactor = { _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + ) { + $0.authenticationClient.login = { _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) + } + $0.authenticationClient.twoFactor = { _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + } } await store.send(.emailChanged("2fa@pointfree.co")) { @@ -59,14 +59,14 @@ final class LoginCoreTests: XCTestCase { let store = TestStore( initialState: Login.State(), reducer: Login() - ) - - store.dependencies.authenticationClient.login = { _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) - } - store.dependencies.authenticationClient.twoFactor = { _ in - try await Task.sleep(nanoseconds: NSEC_PER_SEC) - return AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + ) { + $0.authenticationClient.login = { _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) + } + $0.authenticationClient.twoFactor = { _ in + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + } } await store.send(.emailChanged("2fa@pointfree.co")) { diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift index e0ce671c9004..0ae922d46e82 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift @@ -11,12 +11,12 @@ final class LoginSwiftUITests: XCTestCase { let store = TestStore( initialState: Login.State(), reducer: Login() - ) - .scope(state: LoginView.ViewState.init, action: Login.Action.init) - - store.dependencies.authenticationClient.login = { _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + ) { + $0.authenticationClient.login = { _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + } } + .scope(state: LoginView.ViewState.init, action: Login.Action.init) await store.send(.emailChanged("blob@pointfree.co")) { $0.email = "blob@pointfree.co" @@ -43,12 +43,12 @@ final class LoginSwiftUITests: XCTestCase { let store = TestStore( initialState: Login.State(), reducer: Login() - ) - .scope(state: LoginView.ViewState.init, action: Login.Action.init) - - store.dependencies.authenticationClient.login = { _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) + ) { + $0.authenticationClient.login = { _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) + } } + .scope(state: LoginView.ViewState.init, action: Login.Action.init) await store.send(.emailChanged("2fa@pointfree.co")) { $0.email = "2fa@pointfree.co" @@ -79,12 +79,12 @@ final class LoginSwiftUITests: XCTestCase { let store = TestStore( initialState: Login.State(), reducer: Login() - ) - .scope(state: LoginView.ViewState.init, action: Login.Action.init) - - store.dependencies.authenticationClient.login = { _ in - throw AuthenticationError.invalidUserPassword + ) { + $0.authenticationClient.login = { _ in + throw AuthenticationError.invalidUserPassword + } } + .scope(state: LoginView.ViewState.init, action: Login.Action.init) await store.send(.emailChanged("blob")) { $0.email = "blob" diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift index 5c59cdf2f80a..81de93ffb453 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift @@ -9,10 +9,10 @@ final class TwoFactorCoreTests: XCTestCase { let store = TestStore( initialState: TwoFactor.State(token: "deadbeefdeadbeef"), reducer: TwoFactor() - ) - - store.dependencies.authenticationClient.twoFactor = { _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + ) { + $0.authenticationClient.twoFactor = { _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + } } await store.send(.codeChanged("1")) { @@ -44,10 +44,10 @@ final class TwoFactorCoreTests: XCTestCase { let store = TestStore( initialState: TwoFactor.State(token: "deadbeefdeadbeef"), reducer: TwoFactor() - ) - - store.dependencies.authenticationClient.twoFactor = { _ in - throw AuthenticationError.invalidTwoFactor + ) { + $0.authenticationClient.twoFactor = { _ in + throw AuthenticationError.invalidTwoFactor + } } await store.send(.codeChanged("1234")) { diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorSwiftUITests/TwoFactorSwiftUITests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorSwiftUITests/TwoFactorSwiftUITests.swift index f1ee4321ff1a..b3ae287672cb 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorSwiftUITests/TwoFactorSwiftUITests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorSwiftUITests/TwoFactorSwiftUITests.swift @@ -11,12 +11,12 @@ final class TwoFactorSwiftUITests: XCTestCase { let store = TestStore( initialState: TwoFactor.State(token: "deadbeefdeadbeef"), reducer: TwoFactor() - ) - .scope(state: TwoFactorView.ViewState.init, action: TwoFactor.Action.init) - - store.dependencies.authenticationClient.twoFactor = { _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + ) { + $0.authenticationClient.twoFactor = { _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + } } + .scope(state: TwoFactorView.ViewState.init, action: TwoFactor.Action.init) await store.send(.codeChanged("1")) { $0.code = "1" @@ -51,12 +51,12 @@ final class TwoFactorSwiftUITests: XCTestCase { let store = TestStore( initialState: TwoFactor.State(token: "deadbeefdeadbeef"), reducer: TwoFactor() - ) - .scope(state: TwoFactorView.ViewState.init, action: TwoFactor.Action.init) - - store.dependencies.authenticationClient.twoFactor = { _ in - throw AuthenticationError.invalidTwoFactor + ) { + $0.authenticationClient.twoFactor = { _ in + throw AuthenticationError.invalidTwoFactor + } } + .scope(state: TwoFactorView.ViewState.init, action: TwoFactor.Action.init) await store.send(.codeChanged("1234")) { $0.code = "1234" diff --git a/Examples/Todos/TodosTests/TodosTests.swift b/Examples/Todos/TodosTests/TodosTests.swift index f66e0b9aefeb..717704c2c349 100644 --- a/Examples/Todos/TodosTests/TodosTests.swift +++ b/Examples/Todos/TodosTests/TodosTests.swift @@ -11,9 +11,9 @@ final class TodosTests: XCTestCase { let store = TestStore( initialState: Todos.State(), reducer: Todos() - ) - - store.dependencies.uuid = .incrementing + ) { + $0.uuid = .incrementing + } await store.send(.addTodoButtonTapped) { $0.todos.insert( @@ -84,9 +84,9 @@ final class TodosTests: XCTestCase { let store = TestStore( initialState: state, reducer: Todos() - ) - - store.dependencies.continuousClock = self.clock + ) { + $0.continuousClock = self.clock + } await store.send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { $0.todos[id: state.todos[0].id]?.isComplete = true @@ -119,9 +119,9 @@ final class TodosTests: XCTestCase { let store = TestStore( initialState: state, reducer: Todos() - ) - - store.dependencies.continuousClock = self.clock + ) { + $0.continuousClock = self.clock + } await store.send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { $0.todos[id: state.todos[0].id]?.isComplete = true @@ -255,9 +255,9 @@ final class TodosTests: XCTestCase { let store = TestStore( initialState: state, reducer: Todos() - ) - - store.dependencies.continuousClock = self.clock + ) { + $0.continuousClock = self.clock + } await store.send(.editModeChanged(.active)) { $0.editMode = .active @@ -302,10 +302,10 @@ final class TodosTests: XCTestCase { let store = TestStore( initialState: state, reducer: Todos() - ) - - store.dependencies.continuousClock = self.clock - store.dependencies.uuid = .incrementing + ) { + $0.continuousClock = self.clock + $0.uuid = .incrementing + } await store.send(.editModeChanged(.active)) { $0.editMode = .active diff --git a/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift b/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift index a007708f26dd..ffefa5def436 100644 --- a/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift +++ b/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift @@ -11,25 +11,26 @@ final class VoiceMemosTests: XCTestCase { // NB: Combine's concatenation behavior is different in 13.3 guard #available(iOS 13.4, *) else { return } + let didFinish = AsyncThrowingStream.streamWithContinuation() + let store = TestStore( initialState: VoiceMemos.State(), reducer: VoiceMemos() - ) - - let didFinish = AsyncThrowingStream.streamWithContinuation() - store.dependencies.audioRecorder.currentTime = { 2.5 } - store.dependencies.audioRecorder.requestRecordPermission = { true } - store.dependencies.audioRecorder.startRecording = { _ in - try await didFinish.stream.first { _ in true }! - } - store.dependencies.audioRecorder.stopRecording = { - didFinish.continuation.yield(true) - didFinish.continuation.finish() + ) { + $0.audioRecorder.currentTime = { 2.5 } + $0.audioRecorder.requestRecordPermission = { true } + $0.audioRecorder.startRecording = { _ in + try await didFinish.stream.first { _ in true }! + } + $0.audioRecorder.stopRecording = { + didFinish.continuation.yield(true) + didFinish.continuation.finish() + } + $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) + $0.continuousClock = self.clock + $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } + $0.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!) } - store.dependencies.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - store.dependencies.continuousClock = self.clock - store.dependencies.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - store.dependencies.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!) await store.send(.recordButtonTapped) await self.clock.advance() @@ -76,15 +77,14 @@ final class VoiceMemosTests: XCTestCase { } func testPermissionDenied() async { + var didOpenSettings = false let store = TestStore( initialState: VoiceMemos.State(), reducer: VoiceMemos() - ) - - var didOpenSettings = false - - store.dependencies.audioRecorder.requestRecordPermission = { false } - store.dependencies.openSettings = { @MainActor in didOpenSettings = true } + ) { + $0.audioRecorder.requestRecordPermission = { false } + $0.openSettings = { @MainActor in didOpenSettings = true } + } await store.send(.recordButtonTapped) await store.receive(.recordPermissionResponse(false)) { @@ -99,23 +99,23 @@ final class VoiceMemosTests: XCTestCase { } func testRecordMemoFailure() async { - let store = TestStore( - initialState: VoiceMemos.State(), - reducer: VoiceMemos() - ) - struct SomeError: Error, Equatable {} let didFinish = AsyncThrowingStream.streamWithContinuation() - store.dependencies.audioRecorder.currentTime = { 2.5 } - store.dependencies.audioRecorder.requestRecordPermission = { true } - store.dependencies.audioRecorder.startRecording = { _ in - try await didFinish.stream.first { _ in true }! + let store = TestStore( + initialState: VoiceMemos.State(), + reducer: VoiceMemos() + ) { + $0.audioRecorder.currentTime = { 2.5 } + $0.audioRecorder.requestRecordPermission = { true } + $0.audioRecorder.startRecording = { _ in + try await didFinish.stream.first { _ in true }! + } + $0.continuousClock = self.clock + $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) + $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } + $0.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!) } - store.dependencies.continuousClock = self.clock - store.dependencies.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - store.dependencies.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - store.dependencies.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!) await store.send(.recordButtonTapped) await store.receive(.recordPermissionResponse(true)) { @@ -141,24 +141,24 @@ final class VoiceMemosTests: XCTestCase { // Demonstration of how to write a non-exhaustive test for recording a memo and it failing to // record. func testRecordMemoFailure_NonExhaustive() async { - let store = TestStore( - initialState: VoiceMemos.State(), - reducer: VoiceMemos() - ) - store.exhaustivity = .off(showSkippedAssertions: true) - struct SomeError: Error, Equatable {} let didFinish = AsyncThrowingStream.streamWithContinuation() - store.dependencies.audioRecorder.currentTime = { 2.5 } - store.dependencies.audioRecorder.requestRecordPermission = { true } - store.dependencies.audioRecorder.startRecording = { _ in - try await didFinish.stream.first { _ in true }! + let store = TestStore( + initialState: VoiceMemos.State(), + reducer: VoiceMemos() + ) { + $0.audioRecorder.currentTime = { 2.5 } + $0.audioRecorder.requestRecordPermission = { true } + $0.audioRecorder.startRecording = { _ in + try await didFinish.stream.first { _ in true }! + } + $0.continuousClock = self.clock + $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) + $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } + $0.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!) } - store.dependencies.continuousClock = self.clock - store.dependencies.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - store.dependencies.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - store.dependencies.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!) + store.exhaustivity = .off(showSkippedAssertions: true) await store.send(.recordButtonTapped) await store.send(.recordingMemo(.task)) @@ -184,13 +184,13 @@ final class VoiceMemosTests: XCTestCase { ] ), reducer: VoiceMemos() - ) - - store.dependencies.audioPlayer.play = { _ in - try await self.clock.sleep(for: .milliseconds(1_250)) - return true + ) { + $0.audioPlayer.play = { _ in + try await self.clock.sleep(for: .milliseconds(1_250)) + return true + } + $0.continuousClock = self.clock } - store.dependencies.continuousClock = self.clock let task = await store.send(.voiceMemo(id: url, action: .playButtonTapped)) { $0.voiceMemos[id: url]?.mode = .playing(progress: 0) @@ -211,6 +211,8 @@ final class VoiceMemosTests: XCTestCase { } func testPlayMemoFailure() async { + struct SomeError: Error, Equatable {} + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") let store = TestStore( initialState: VoiceMemos.State( @@ -225,12 +227,10 @@ final class VoiceMemosTests: XCTestCase { ] ), reducer: VoiceMemos() - ) - - struct SomeError: Error, Equatable {} - - store.dependencies.audioPlayer.play = { _ in throw SomeError() } - store.dependencies.continuousClock = self.clock + ) { + $0.audioPlayer.play = { _ in throw SomeError() } + $0.continuousClock = self.clock + } let task = await store.send(.voiceMemo(id: url, action: .playButtonTapped)) { $0.voiceMemos[id: url]?.mode = .playing(progress: 0) @@ -302,10 +302,10 @@ final class VoiceMemosTests: XCTestCase { ] ), reducer: VoiceMemos() - ) - - store.dependencies.audioPlayer.play = { _ in try await Task.never() } - store.dependencies.continuousClock = self.clock + ) { + $0.audioPlayer.play = { _ in try await Task.never() } + $0.continuousClock = self.clock + } await store.send(.voiceMemo(id: url, action: .playButtonTapped)) { $0.voiceMemos[id: url]?.mode = .playing(progress: 0) diff --git a/README.md b/README.md index 4c0622ffefce..8fa276730f49 100644 --- a/README.md +++ b/README.md @@ -530,9 +530,9 @@ override any dependency you need to for the purpose of the test: let store = TestStore( initialState: Feature.State(), reducer: Feature() -) - -store.dependencies.numberFact.fetch = { "\($0) is a good number Brent" } +) { + $0.numberFact.fetch = { "\($0) is a good number Brent" } +} … ``` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md index 1850051331bd..41387dcb6746 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md @@ -465,9 +465,9 @@ override any dependency you need to for the purpose of the test: let store = TestStore( initialState: Feature.State(), reducer: Feature() -) - -store.dependencies.numberFact.fetch = { "\($0) is a good number Brent" } +) { + $0.numberFact.fetch = { "\($0) is a good number Brent" } +} await store.send(.numberFactButtonTapped) await store.receive(.numberFactResponse(.success("0 is a good number Brent"))) { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md index 0c82be5e930c..e5d33ff32fda 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md @@ -577,11 +577,25 @@ By default test stores will employ "test" dependencies wherever a dependency is reducer via the `@Dependency` property wrapper. Instead of passing an environment of test dependencies to the store, or mutating the store's -``TestStore/environment``, you will instead mutate the test store's ``TestStore/dependencies`` to +``TestStore/environment``, you can either provide a trailing closure when initializing ``TestStore`` +or you can directly mutate the test store's ``TestStore/dependencies`` to override dependencies driving a feature. For example, to install a test clock as the continuous clock dependency you can do the following: +```swift +let clock = TestClock() + +let store = TestStore( + initialState: Feature.State(), + reducer: Feature() +) { + $0.continuousClock = .clock +} +``` + +…or you can do: + ```swift let clock = TestClock() store.dependencies.continuousClock = clock diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md index dc8b78590083..1aa2317e8180 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md @@ -365,9 +365,9 @@ as an immediate clock that does not suspend at all when you ask it to sleep: let store = TestStore( initialState: Feature.State(count: 0), reducer: Feature() -) - -store.dependencies.continuousClock = ImmediateClock() +) { + $0.continuousClock = ImmediateClock() +} ``` With that small change we can drop the `timeout` arguments from the diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index 521ee50cb51a..43d677932ab6 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -11,7 +11,7 @@ import XCTestDynamicOverlay """ 'EffectPublisher' has been deprecated in favor of 'EffectTask'. - You are encouraged to use `EffectTask` to model the ouput of your reducers, and to use Swift concurrency to model asynchrony in dependencies. + You are encouraged to use `EffectTask` to model the output of your reducers, and to use Swift concurrency to model asynchrony in dependencies. See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 """ @@ -23,7 +23,7 @@ import XCTestDynamicOverlay """ 'EffectPublisher' has been deprecated in favor of 'EffectTask'. - You are encouraged to use `EffectTask` to model the ouput of your reducers, and to use Swift concurrency to model asynchrony in dependencies. + You are encouraged to use `EffectTask` to model the output of your reducers, and to use Swift concurrency to model asynchrony in dependencies. See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 """ @@ -35,7 +35,7 @@ import XCTestDynamicOverlay """ 'EffectPublisher' has been deprecated in favor of 'EffectTask'. - You are encouraged to use `EffectTask` to model the ouput of your reducers, and to use Swift concurrency to model asynchrony in dependencies. + You are encouraged to use `EffectTask` to model the output of your reducers, and to use Swift concurrency to model asynchrony in dependencies. See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 """ @@ -47,7 +47,7 @@ import XCTestDynamicOverlay """ 'EffectPublisher' has been deprecated in favor of 'EffectTask'. - You are encouraged to use `EffectTask` to model the ouput of your reducers, and to use Swift concurrency to model asynchrony in dependencies. + You are encouraged to use `EffectTask` to model the output of your reducers, and to use Swift concurrency to model asynchrony in dependencies. See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 """ diff --git a/Sources/ComposableArchitecture/Effects/Publisher/Timer.swift b/Sources/ComposableArchitecture/Effects/Publisher/Timer.swift index 74943b5f6602..5ff069ec8b19 100644 --- a/Sources/ComposableArchitecture/Effects/Publisher/Timer.swift +++ b/Sources/ComposableArchitecture/Effects/Publisher/Timer.swift @@ -55,9 +55,9 @@ extension EffectPublisher where Failure == Never { /// let store = TestStore( /// initialState: Feature.State(), /// reducer: Feature() - /// ) - /// - /// store.dependencies.mainQueue = mainQueue.eraseToAnyScheduler() + /// ) { + /// $0.mainQueue = mainQueue.eraseToAnyScheduler() + /// } /// /// await store.send(.startButtonTapped) /// diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 58f0cf39db98..196790a81867 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -142,14 +142,23 @@ public final class Store { /// - initialState: The state to start the application in. /// - reducer: The reducer that powers the business logic of the application. public convenience init( - initialState: R.State, - reducer: R + initialState: @autoclosure () -> R.State, + reducer: R, + prepareDependencies: ((inout DependencyValues) -> Void)? = nil ) where R.State == State, R.Action == Action { - self.init( - initialState: initialState, - reducer: reducer, - mainThreadChecksEnabled: true - ) + if let prepareDependencies = prepareDependencies { + self.init( + initialState: withDependencies(prepareDependencies) { initialState() }, + reducer: reducer.transformDependency(\.self, transform: prepareDependencies), + mainThreadChecksEnabled: true + ) + } else { + self.init( + initialState: initialState(), + reducer: reducer, + mainThreadChecksEnabled: true + ) + } } /// Scopes the store to one that exposes child state and actions. diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index a6b94f2dfa06..7cb89d22d9a9 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -165,19 +165,21 @@ import XCTestDynamicOverlay /// values that are fully controlled and deterministic: /// /// ```swift +/// // Create a test clock to control the timing of effects +/// let clock = TestClock() +/// /// let store = TestStore( /// initialState: Search.State(), /// reducer: Search() -/// ) +/// ) { +/// // Override the clock dependency with the test clock +/// $0.continuousClock = clock /// -/// // Simulate a search response with one item -/// store.dependencies.apiClient.search = { _ in -/// ["Composable Architecture"] -/// } -/// -/// // Create a test clock to control the timing of effects -/// let clock = TestClock() -/// store.dependencies.continuousClock = clock +/// // Simulate a search response with one item +/// $0.apiClient.search = { _ in +/// ["Composable Architecture"] +/// } +/// ) /// /// // Change the query /// await store.send(.searchFieldChanged("c") { @@ -433,10 +435,10 @@ public final class TestStore EffectTask { + state = self.uuid() + return .none + } + } + + @Dependency(\.uuid) var uuid + + let store = Store(initialState: uuid(), reducer: MyReducer()) { + $0.uuid = .constant(UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) + } + let viewStore = ViewStore(store, observe: { $0 }) + + XCTAssertEqual(viewStore.state, UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) + } } diff --git a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift index d74e88308dc1..e55507667dbe 100644 --- a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift @@ -673,9 +673,10 @@ let store = TestStore( initialState: KrzysztofExample.State(), reducer: KrzysztofExample() - ) + ) { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } store.exhaustivity = .off - store.dependencies.mainQueue = mainQueue.eraseToAnyScheduler() store.send(.advanceAgeAndMoodAfterDelay) mainQueue.advance(by: 1) diff --git a/Tests/ComposableArchitectureTests/TestStoreTests.swift b/Tests/ComposableArchitectureTests/TestStoreTests.swift index 5189b01e7015..1eac6a1c8fdb 100644 --- a/Tests/ComposableArchitectureTests/TestStoreTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreTests.swift @@ -301,6 +301,60 @@ final class TestStoreTests: XCTestCase { store.send(true) { $0 = 1 } } + func testOverrideDependenciesOnTestStore_MidwayChange() { + struct Counter: ReducerProtocol { + @Dependency(\.date.now) var now + + func reduce(into state: inout Int, action: ()) -> EffectTask { + state = Int(self.now.timeIntervalSince1970) + return .none + } + } + + let store = TestStore( + initialState: 0, + reducer: Counter() + ) { + $0.date.now = Date(timeIntervalSince1970: 1234567890) + } + + store.send(()) { $0 = 1234567890 } + + store.dependencies.date.now = Date(timeIntervalSince1970: 987654321) + + store.send(()) { $0 = 987654321 } + } + + func testOverrideDependenciesOnTestStore_Init() { + struct Counter: ReducerProtocol { + @Dependency(\.calendar) var calendar + @Dependency(\.locale) var locale + @Dependency(\.timeZone) var timeZone + @Dependency(\.urlSession) var urlSession + + func reduce(into state: inout Int, action: Bool) -> EffectTask { + _ = self.calendar + _ = self.locale + _ = self.timeZone + _ = self.urlSession + state += action ? 1 : -1 + return .none + } + } + + let store = TestStore( + initialState: 0, + reducer: Counter() + ) { + $0.calendar = Calendar(identifier: .gregorian) + $0.locale = Locale(identifier: "en_US") + $0.timeZone = TimeZone(secondsFromGMT: 0)! + $0.urlSession = URLSession(configuration: .ephemeral) + } + + store.send(true) { $0 = 1 } + } + func testDependenciesEarlyBinding() async { struct Feature: ReducerProtocol { struct State: Equatable { From a4538ebefb976f73c949a36e0c0cebeb1448505d Mon Sep 17 00:00:00 2001 From: mbrandonw Date: Wed, 18 Jan 2023 04:41:29 +0000 Subject: [PATCH 26/38] Run swift-format --- Tests/ComposableArchitectureTests/TestStoreTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/ComposableArchitectureTests/TestStoreTests.swift b/Tests/ComposableArchitectureTests/TestStoreTests.swift index 1eac6a1c8fdb..bedac7df54ac 100644 --- a/Tests/ComposableArchitectureTests/TestStoreTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreTests.swift @@ -315,14 +315,14 @@ final class TestStoreTests: XCTestCase { initialState: 0, reducer: Counter() ) { - $0.date.now = Date(timeIntervalSince1970: 1234567890) + $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) } - store.send(()) { $0 = 1234567890 } + store.send(()) { $0 = 1_234_567_890 } - store.dependencies.date.now = Date(timeIntervalSince1970: 987654321) + store.dependencies.date.now = Date(timeIntervalSince1970: 987_654_321) - store.send(()) { $0 = 987654321 } + store.send(()) { $0 = 987_654_321 } } func testOverrideDependenciesOnTestStore_Init() { From e47342740bf8912d7bb602c78a1a6cb3f267f0cb Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 20 Jan 2023 12:59:59 -0800 Subject: [PATCH 27/38] Testing gotchas (#1854) * Testing gotchas * Update Testing.md Co-authored-by: Stephen Celis --- .../Documentation.docc/Articles/Testing.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md index 1aa2317e8180..e1ac22c6660c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md @@ -10,6 +10,7 @@ but also how effects are executed and feed data back into the system. * [Testing state changes][Testing-state-changes] * [Testing effects][Testing-effects] * [Non-exhaustive testing][Non-exhaustive-testing] +* [Testing gotchas](#Testing-gotchas) ## Testing state changes @@ -553,6 +554,51 @@ The test still passes, and none of these notifications are test failures. They j what things you are not explicitly asserting against, and can be useful to see when tracking down bugs that happen in production but that aren't currently detected in tests. +## Testing gotchas + +This is not well known, but when an application target runs tests it actually boots up a simulator +and runs your actual application entry point in the simulator. This means while tests are running, +your application's code is separately also running. This can be a huge gotcha because it means you +may be unknowingly making network requests, tracking analytics, writing data to user defaults or +to the disk, and more. + +This usually flies under the radar and you just won't know it's happening, which can be problematic. +But, once you start using this library and start controlling your dependencies, the problem can +surface in a very visible manner. Typically, when a dependency is used in a test context without +being overridden, a test failure occurs. This makes it possible for your test to pass successfully, +yet for some mysterious reason the test suite fails. This happens because the code in the _app +host_ is now running in a test context, and accessing dependencies will cause test failures. + +This only happens when running tests in a _application target_, that is, a target that is +specifically used to launch the application for a simulator or device. This does not happen when +running tests for frameworks or SPM libraries, which is yet another good reason to modularize +your code base. + +However, if you aren't in a position to modularize your code base right now, there is a quick +fix. Our [XCTest Dynamic Overlay][xctest-dynamic-overlay-gh] library, which is transitively included +with this library, comes with a property you can check to see if tests are currently running. If +they are, you can omit the entire entry point of your application: + +```swift +import SwiftUI +import XCTestDynamicOverlay + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + if !_XCTIsTesting { + // Your real root view + } + } + } +} +``` + +That will allow tests to run in the application target without your actual application code +interfering. + +[xctest-dynamic-overlay-gh]: http://github.com/pointfreeco/xctest-dynamic-overlay [Testing-state-changes]: #Testing-state-changes [Testing-effects]: #Testing-effects [gh-combine-schedulers]: http://github.com/pointfreeco/combine-schedulers From 52c4a01437aa1429dab4e6b460062a8b7d6aeab6 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 20 Jan 2023 14:56:53 -0800 Subject: [PATCH 28/38] Rename `BindableState` to `BindingState` (#1855) The -`able` naming evokes protocols in Swift, and is an outlier when considered alongside the rest of TCA's binding tools: - `BindingAction`: concrete type - `BindableAction`: protocol - `BindingReducer`: concrete type So, let's make things consistent. The one caveat is that Swift diagnostics for such a deprecation aren't great, so users won't get proactive warnings here for the time being: https://github.com/apple/swift/issues/63139 We may just want to keep the deprecation around till it does... --- .../01-GettingStarted-Bindings-Forms.swift | 10 ++-- .../01-GettingStarted-FocusState.swift | 6 +-- .../Documentation.docc/Articles/Bindings.md | 16 +++---- .../Documentation.docc/Extensions/SwiftUI.md | 2 +- .../Internal/Deprecations.swift | 22 ++++++--- .../SwiftUI/Binding.swift | 46 +++++++++---------- .../BindingTests.swift | 4 +- .../DebugTests.swift | 4 +- .../RuntimeWarningTests.swift | 2 +- 9 files changed, 60 insertions(+), 52 deletions(-) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift index 45fd0ca136a0..6222fcaca493 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift @@ -8,7 +8,7 @@ private let readMe = """ Bindable state and actions allow you to safely eliminate the boilerplate caused by needing to \ have a unique action for every UI control. Instead, all UI bindings can be consolidated into a \ single `binding` action that holds onto a `BindingAction` value, and all bindable state can be \ - safeguarded with the `BindableState` property wrapper. + safeguarded with the `BindingState` property wrapper. It is instructive to compare this case study to the "Binding Basics" case study. """ @@ -17,10 +17,10 @@ private let readMe = """ struct BindingForm: ReducerProtocol { struct State: Equatable { - @BindableState var sliderValue = 5.0 - @BindableState var stepCount = 10 - @BindableState var text = "" - @BindableState var toggleIsOn = false + @BindingState var sliderValue = 5.0 + @BindingState var stepCount = 10 + @BindingState var text = "" + @BindingState var toggleIsOn = false } enum Action: BindableAction, Equatable { diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift index d91aaacf95e6..505c7bb154eb 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift @@ -10,9 +10,9 @@ private let readMe = """ struct FocusDemo: ReducerProtocol { struct State: Equatable { - @BindableState var focusedField: Field? - @BindableState var password: String = "" - @BindableState var username: String = "" + @BindingState var focusedField: Field? + @BindingState var password: String = "" + @BindingState var username: String = "" enum Field: String, Hashable { case username, password diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Bindings.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Bindings.md index 47b11825acae..7a409b98d487 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/Bindings.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Bindings.md @@ -177,20 +177,20 @@ struct Settings: ReducerProtocol { ``` This is a _lot_ of boilerplate for something that should be simple. Luckily, we can dramatically -eliminate this boilerplate using ``BindableState``, ``BindableAction``, and ``BindingReducer``. +eliminate this boilerplate using ``BindingState``, ``BindableAction``, and ``BindingReducer``. -First, we can annotate each bindable value of state with the ``BindableState`` property wrapper: +First, we can annotate each bindable value of state with the ``BindingState`` property wrapper: ```swift struct Settings: ReducerProtocol { struct State: Equatable { - @BindableState var digest = Digest.daily - @BindableState var displayName = "" - @BindableState var enableNotifications = false + @BindingState var digest = Digest.daily + @BindingState var displayName = "" + @BindingState var enableNotifications = false var isLoading = false - @BindableState var protectMyPosts = false - @BindableState var sendEmailNotifications = false - @BindableState var sendMobileNotifications = false + @BindingState var protectMyPosts = false + @BindingState var sendEmailNotifications = false + @BindingState var sendMobileNotifications = false } // ... diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUI.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUI.md index a1f14953c60f..f952185143d3 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUI.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUI.md @@ -19,7 +19,7 @@ The Composable Architecture can be used to power applications built in many fram - - ``ViewStore/binding(get:send:)-65xes`` -- ``BindableState`` +- ``BindingState`` - ``BindableAction`` - ``BindingAction`` - ``BindingReducer`` diff --git a/Sources/ComposableArchitecture/Internal/Deprecations.swift b/Sources/ComposableArchitecture/Internal/Deprecations.swift index 80dd2c9394aa..5394fd911eca 100644 --- a/Sources/ComposableArchitecture/Internal/Deprecations.swift +++ b/Sources/ComposableArchitecture/Internal/Deprecations.swift @@ -3,6 +3,14 @@ import Combine import SwiftUI import XCTestDynamicOverlay +// MARK: - Deprecated after 0.49.2 + +// NB: As of Swift 5.7, property wrapper deprecations are not diagnosed, so we may want to keep this +// deprecation around for now: +// https://github.com/apple/swift/issues/63139 +@available(*, deprecated, renamed: "BindingState") +public typealias BindableState = BindingState + // MARK: - Deprecated after 0.47.2 extension ActorIsolated { @@ -972,7 +980,7 @@ extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewSt ) @MainActor public subscript( - dynamicMember keyPath: WritableKeyPath> + dynamicMember keyPath: WritableKeyPath> ) -> Binding { self.binding( get: { $0[keyPath: keyPath].wrappedValue }, @@ -988,8 +996,8 @@ extension BindingAction { *, deprecated, message: """ - For improved safety, bindable properties must now be wrapped explicitly in 'BindableState', \ - and accessed via key paths to that 'BindableState', like '\\.$value' + For improved safety, bindable properties must now be wrapped explicitly in 'BindingState', \ + and accessed via key paths to that 'BindingState', like '\\.$value' """ ) public static func set( @@ -1008,8 +1016,8 @@ extension BindingAction { *, deprecated, message: """ - For improved safety, bindable properties must now be wrapped explicitly in 'BindableState', \ - and accessed via key paths to that 'BindableState', like '\\.$value' + For improved safety, bindable properties must now be wrapped explicitly in 'BindingState', \ + and accessed via key paths to that 'BindingState', like '\\.$value' """ ) public static func ~= ( @@ -1042,8 +1050,8 @@ extension ViewStore { *, deprecated, message: """ - For improved safety, bindable properties must now be wrapped explicitly in 'BindableState'. \ - Bindings are now derived via 'ViewStore.binding' with a key path to that 'BindableState' \ + For improved safety, bindable properties must now be wrapped explicitly in 'BindingState'. \ + Bindings are now derived via 'ViewStore.binding' with a key path to that 'BindingState' \ (for example, 'viewStore.binding(\\.$value)'). For dynamic member lookup to be available, \ the view store's 'Action' type must also conform to 'BindableAction'. """ diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index a56c59eb9ed6..21ab0d69904f 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -11,7 +11,7 @@ import SwiftUI /// Read for more information. @dynamicMemberLookup @propertyWrapper -public struct BindableState { +public struct BindingState { /// The underlying value wrapped by the bindable state. public var wrappedValue: Value @@ -23,13 +23,13 @@ public struct BindableState { /// A projection that can be used to derive bindings from a view store. /// /// Use the projected value to derive bindings from a view store with properties annotated with - /// `@BindableState`. To get the `projectedValue`, prefix the property with `$`: + /// `@BindingState`. To get the `projectedValue`, prefix the property with `$`: /// /// ```swift /// TextField("Display name", text: viewStore.binding(\.$displayName)) /// ``` /// - /// See ``BindableState`` for more details. + /// See ``BindingState`` for more details. public var projectedValue: Self { get { self } set { self = newValue } @@ -41,17 +41,17 @@ public struct BindableState { /// - Returns: A new bindable state. public subscript( dynamicMember keyPath: WritableKeyPath - ) -> BindableState { + ) -> BindingState { get { .init(wrappedValue: self.wrappedValue[keyPath: keyPath]) } set { self.wrappedValue[keyPath: keyPath] = newValue.wrappedValue } } } -extension BindableState: Equatable where Value: Equatable {} +extension BindingState: Equatable where Value: Equatable {} -extension BindableState: Hashable where Value: Hashable {} +extension BindingState: Hashable where Value: Hashable {} -extension BindableState: Decodable where Value: Decodable { +extension BindingState: Decodable where Value: Decodable { public init(from decoder: Decoder) throws { do { let container = try decoder.singleValueContainer() @@ -62,7 +62,7 @@ extension BindableState: Decodable where Value: Decodable { } } -extension BindableState: Encodable where Value: Encodable { +extension BindingState: Encodable where Value: Encodable { public func encode(to encoder: Encoder) throws { do { var container = encoder.singleValueContainer() @@ -73,29 +73,29 @@ extension BindableState: Encodable where Value: Encodable { } } -extension BindableState: CustomReflectable { +extension BindingState: CustomReflectable { public var customMirror: Mirror { Mirror(reflecting: self.wrappedValue) } } -extension BindableState: CustomDumpRepresentable { +extension BindingState: CustomDumpRepresentable { public var customDumpValue: Any { self.wrappedValue } } -extension BindableState: CustomDebugStringConvertible where Value: CustomDebugStringConvertible { +extension BindingState: CustomDebugStringConvertible where Value: CustomDebugStringConvertible { public var debugDescription: String { self.wrappedValue.debugDescription } } -extension BindableState: Sendable where Value: Sendable {} +extension BindingState: Sendable where Value: Sendable {} /// An action type that exposes a `binding` case that holds a ``BindingAction``. /// -/// Used in conjunction with ``BindableState`` to safely eliminate the boilerplate typically +/// Used in conjunction with ``BindingState`` to safely eliminate the boilerplate typically /// associated with mutating multiple fields in state. /// /// Read for more information. @@ -116,7 +116,7 @@ extension BindableAction { /// /// - Returns: A binding action. public static func set( - _ keyPath: WritableKeyPath>, + _ keyPath: WritableKeyPath>, _ value: Value ) -> Self { self.binding(.set(keyPath, value)) @@ -129,7 +129,7 @@ extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewSt /// - Parameter keyPath: A key path to a specific bindable state. /// - Returns: A new binding. public func binding( - _ keyPath: WritableKeyPath>, + _ keyPath: WritableKeyPath>, file: StaticString = #file, fileID: StaticString = #fileID, line: UInt = #line @@ -157,7 +157,7 @@ extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewSt /// An action that describes simple mutations to some root state at a writable key path. /// -/// Used in conjunction with ``BindableState`` and ``BindableAction`` to safely eliminate the +/// Used in conjunction with ``BindingState`` and ``BindableAction`` to safely eliminate the /// boilerplate typically associated with mutating multiple fields in state. /// /// Read for more information. @@ -180,12 +180,12 @@ extension BindingAction { /// /// - Parameters: /// - keyPath: A key path to the property that should be mutated. This property must be - /// annotated with the ``BindableState`` property wrapper. + /// annotated with the ``BindingState`` property wrapper. /// - value: A value to assign at the given key path. /// - Returns: An action that describes simple mutations to some root state at a writable key /// path. public static func set( - _ keyPath: WritableKeyPath>, + _ keyPath: WritableKeyPath>, _ value: Value ) -> Self { return .init( @@ -208,14 +208,14 @@ extension BindingAction { /// // Return an authorization request effect /// ``` public static func ~= ( - keyPath: WritableKeyPath>, + keyPath: WritableKeyPath>, bindingAction: Self ) -> Bool { keyPath == bindingAction.keyPath } init( - keyPath: WritableKeyPath>, + keyPath: WritableKeyPath>, set: @escaping (inout Root) -> Void, value: Value ) { @@ -233,7 +233,7 @@ extension BindingAction { /// key path. /// /// Useful in transforming binding actions on view state into binding actions on reducer state - /// when the domain contains ``BindableState`` and ``BindableAction``. + /// when the domain contains ``BindingState`` and ``BindableAction``. /// /// For example, we can model an feature that can bind an integer count to a stepper and make a /// network request to fetch a fact about that integer with the following domain: @@ -241,7 +241,7 @@ extension BindingAction { /// ```swift /// struct MyFeature: ReducerProtocol { /// struct State: Equatable { - /// @BindableState var count = 0 + /// @BindingState var count = 0 /// var fact: String? /// ... /// } @@ -279,7 +279,7 @@ extension BindingAction { /// ```swift /// extension MyFeatureView { /// struct ViewState: Equatable { - /// @BindableState var count: Int + /// @BindingState var count: Int /// let fact: String? /// // no access to any other state on `MyFeature.State`, like child domains /// } diff --git a/Tests/ComposableArchitectureTests/BindingTests.swift b/Tests/ComposableArchitectureTests/BindingTests.swift index db0033f06e8d..976c1e5618c4 100644 --- a/Tests/ComposableArchitectureTests/BindingTests.swift +++ b/Tests/ComposableArchitectureTests/BindingTests.swift @@ -4,10 +4,10 @@ import XCTest @MainActor final class BindingTests: XCTestCase { #if swift(>=5.7) - func testNestedBindableState() { + func testNestedBindingState() { struct BindingTest: ReducerProtocol { struct State: Equatable { - @BindableState var nested = Nested() + @BindingState var nested = Nested() struct Nested: Equatable { var field = "" diff --git a/Tests/ComposableArchitectureTests/DebugTests.swift b/Tests/ComposableArchitectureTests/DebugTests.swift index f7dc324ca04e..785b0fea5ed7 100644 --- a/Tests/ComposableArchitectureTests/DebugTests.swift +++ b/Tests/ComposableArchitectureTests/DebugTests.swift @@ -45,7 +45,7 @@ func testBindingAction() { struct State { - @BindableState var width = 0 + @BindingState var width = 0 } let action = BindingAction.set(\State.$width, 50) var dump = "" @@ -54,7 +54,7 @@ dump, #""" BindingAction.set( - WritableKeyPath>, + WritableKeyPath>, 50 ) """# diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index 482f96a68566..871ace1b4748 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -190,7 +190,7 @@ @MainActor func testBindingUnhandledAction() { struct State: Equatable { - @BindableState var value = 0 + @BindingState var value = 0 } enum Action: BindableAction, Equatable { case binding(BindingAction) From 0ed5c83d9608518f8ea6ae757b58f42f33ad407f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 23 Jan 2023 10:44:43 -0800 Subject: [PATCH 29/38] Deprecate `TestStore.init` that doesn't require equatable state (#1857) And introduce `TestStore.init(initialState:reducer:observe:send:)` for testing scoped state and actions. --- .../GameSwiftUITests/GameSwiftUITests.swift | 5 +- .../LoginSwiftUITests/LoginSwiftUITests.swift | 15 +- .../NewGameSwiftUITests.swift | 5 +- .../TwoFactorSwiftUITests.swift | 10 +- .../ComposableArchitecture/TestStore.swift | 152 +++++++++++++++++- 5 files changed, 169 insertions(+), 18 deletions(-) diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/GameSwiftUITests/GameSwiftUITests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/GameSwiftUITests/GameSwiftUITests.swift index cd88dfbeeb62..65b4e564d524 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/GameSwiftUITests/GameSwiftUITests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/GameSwiftUITests/GameSwiftUITests.swift @@ -11,9 +11,10 @@ final class GameSwiftUITests: XCTestCase { oPlayerName: "Blob Jr.", xPlayerName: "Blob Sr." ), - reducer: Game() + reducer: Game(), + observe: GameView.ViewState.init, + send: { $0 } ) - .scope(state: GameView.ViewState.init) func testFlow_Winner_Quit() async { await self.store.send(.cellTapped(row: 0, column: 0)) { diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift index 0ae922d46e82..3950f617a9f0 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift @@ -10,13 +10,14 @@ final class LoginSwiftUITests: XCTestCase { func testFlow_Success() async { let store = TestStore( initialState: Login.State(), - reducer: Login() + reducer: Login(), + observe: LoginView.ViewState.init, + send: action: Login.Action.init ) { $0.authenticationClient.login = { _ in AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) } } - .scope(state: LoginView.ViewState.init, action: Login.Action.init) await store.send(.emailChanged("blob@pointfree.co")) { $0.email = "blob@pointfree.co" @@ -42,13 +43,14 @@ final class LoginSwiftUITests: XCTestCase { func testFlow_Success_TwoFactor() async { let store = TestStore( initialState: Login.State(), - reducer: Login() + reducer: Login(), + observe: LoginView.ViewState.init, + send: action: Login.Action.init ) { $0.authenticationClient.login = { _ in AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) } } - .scope(state: LoginView.ViewState.init, action: Login.Action.init) await store.send(.emailChanged("2fa@pointfree.co")) { $0.email = "2fa@pointfree.co" @@ -78,13 +80,14 @@ final class LoginSwiftUITests: XCTestCase { func testFlow_Failure() async { let store = TestStore( initialState: Login.State(), - reducer: Login() + reducer: Login(), + observe: LoginView.ViewState.init, + send: action: Login.Action.init ) { $0.authenticationClient.login = { _ in throw AuthenticationError.invalidUserPassword } } - .scope(state: LoginView.ViewState.init, action: Login.Action.init) await store.send(.emailChanged("blob")) { $0.email = "blob" diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/NewGameSwiftUITests/NewGameSwiftUITests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/NewGameSwiftUITests/NewGameSwiftUITests.swift index 2a59037dfea7..63eac2213cc2 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/NewGameSwiftUITests/NewGameSwiftUITests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/NewGameSwiftUITests/NewGameSwiftUITests.swift @@ -8,9 +8,10 @@ import XCTest final class NewGameSwiftUITests: XCTestCase { let store = TestStore( initialState: NewGame.State(), - reducer: NewGame() + reducer: NewGame(), + observe: NewGameView.ViewState.init, + send: NewGame.Action.init ) - .scope(state: NewGameView.ViewState.init, action: NewGame.Action.init) func testNewGame() async { await self.store.send(.xPlayerNameChanged("Blob Sr.")) { diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorSwiftUITests/TwoFactorSwiftUITests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorSwiftUITests/TwoFactorSwiftUITests.swift index b3ae287672cb..cc0053d55f6b 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorSwiftUITests/TwoFactorSwiftUITests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorSwiftUITests/TwoFactorSwiftUITests.swift @@ -10,13 +10,14 @@ final class TwoFactorSwiftUITests: XCTestCase { func testFlow_Success() async { let store = TestStore( initialState: TwoFactor.State(token: "deadbeefdeadbeef"), - reducer: TwoFactor() + reducer: TwoFactor(), + observe: TwoFactorView.ViewState.init, + send: TwoFactor.Action.init ) { $0.authenticationClient.twoFactor = { _ in AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) } } - .scope(state: TwoFactorView.ViewState.init, action: TwoFactor.Action.init) await store.send(.codeChanged("1")) { $0.code = "1" @@ -50,13 +51,14 @@ final class TwoFactorSwiftUITests: XCTestCase { func testFlow_Failure() async { let store = TestStore( initialState: TwoFactor.State(token: "deadbeefdeadbeef"), - reducer: TwoFactor() + reducer: TwoFactor(), + observe: TwoFactorView.ViewState.init, + send: TwoFactor.Action.init ) { $0.authenticationClient.twoFactor = { _ in throw AuthenticationError.invalidTwoFactor } } - .scope(state: TwoFactorView.ViewState.init, action: TwoFactor.Action.init) await store.send(.codeChanged("1234")) { $0.code = "1234" diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index 7cb89d22d9a9..57d203ebaf39 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -563,16 +563,146 @@ public final class TestStore( + /// - prepareDependencies: A closure that can be used to override dependencies that will be + /// accessed during the test. These dependencies will be used when producing the initial + /// state. + public convenience init( initialState: @autoclosure () -> State, - reducer: Reducer, + reducer: R, prepareDependencies: (inout DependencyValues) -> Void = { _ in }, file: StaticString = #file, line: UInt = #line ) where - Reducer.State == State, - Reducer.Action == Action, + R.State == State, + R.Action == Action, + State == ScopedState, + State: Equatable, + Action == ScopedAction, + Environment == Void + { + self.init( + initialState: initialState(), + reducer: reducer, + observe: { $0 }, + send: { $0 }, + prepareDependencies: prepareDependencies, + file: file, + line: line + ) + } + + /// Creates a scoped test store with an initial state and a reducer powering its runtime. + /// + /// See and the documentation of ``TestStore`` for more information on how to best + /// use a test store. + /// + /// - Parameters: + /// - initialState: The state the feature starts in. + /// - reducer: The reducer that powers the runtime of the feature. + /// - toScopedState: A function that transforms the reducer's state into scoped state. This + /// state will be asserted against as it is mutated by the reducer. Useful for testing view + /// store state transformations. + /// - prepareDependencies: A closure that can be used to override dependencies that will be + /// accessed during the test. These dependencies will be used when producing the initial + /// state. + public convenience init( + initialState: @autoclosure () -> State, + reducer: R, + observe toScopedState: @escaping (State) -> ScopedState, + prepareDependencies: (inout DependencyValues) -> Void = { _ in }, + file: StaticString = #file, + line: UInt = #line + ) + where + R.State == State, + R.Action == Action, + ScopedState: Equatable, + Action == ScopedAction, + Environment == Void + { + self.init( + initialState: initialState(), + reducer: reducer, + observe: toScopedState, + send: { $0 }, + prepareDependencies: prepareDependencies, + file: file, + line: line + ) + } + + /// Creates a scoped test store with an initial state and a reducer powering its runtime. + /// + /// See and the documentation of ``TestStore`` for more information on how to best + /// use a test store. + /// + /// - Parameters: + /// - initialState: The state the feature starts in. + /// - reducer: The reducer that powers the runtime of the feature. + /// - toScopedState: A function that transforms the reducer's state into scoped state. This + /// state will be asserted against as it is mutated by the reducer. Useful for testing view + /// store state transformations. + /// - fromScopedAction: A function that wraps a more scoped action in the reducer's action. + /// Scoped actions can be "sent" to the store, while any reducer action may be received. + /// Useful for testing view store action transformations. + /// - prepareDependencies: A closure that can be used to override dependencies that will be + /// accessed during the test. These dependencies will be used when producing the initial + /// state. + public init( + initialState: @autoclosure () -> State, + reducer: R, + observe toScopedState: @escaping (State) -> ScopedState, + send fromScopedAction: @escaping (ScopedAction) -> Action, + prepareDependencies: (inout DependencyValues) -> Void = { _ in }, + file: StaticString = #file, + line: UInt = #line + ) + where + R.State == State, + R.Action == Action, + ScopedState: Equatable, + Environment == Void + { + var dependencies = DependencyValues._current + prepareDependencies(&dependencies) + let initialState = withDependencies { + $0 = dependencies + } operation: { + initialState() + } + + let reducer = TestReducer(Reduce(reducer), initialState: initialState) + self._environment = .init(wrappedValue: ()) + self.file = file + self.fromScopedAction = fromScopedAction + self.line = line + self.reducer = reducer + self.store = Store(initialState: initialState, reducer: reducer) + self.timeout = 100 * NSEC_PER_MSEC + self.toScopedState = toScopedState + self.dependencies = dependencies + } + + /// Creates a test store with an initial state and a reducer powering its runtime. + /// + /// See and the documentation of ``TestStore`` for more information on how to best + /// use a test store. + /// + /// - Parameters: + /// - initialState: The state the feature starts in. + /// - reducer: The reducer that powers the runtime of the feature. + @available(*, deprecated, message: "State must be equatable to perform assertions.") + public init( + initialState: @autoclosure () -> State, + reducer: R, + prepareDependencies: (inout DependencyValues) -> Void = { _ in }, + file: StaticString = #file, + line: UInt = #line + ) + where + R.State == State, + R.Action == Action, State == ScopedState, Action == ScopedAction, Environment == Void @@ -1747,6 +1877,13 @@ extension TestStore { /// - fromScopedAction: A function that wraps a more scoped action in the reducer's action. /// Scoped actions can be "sent" to the store, while any reducer action may be received. /// Useful for testing view store action transformations. + @available( + *, + deprecated, + message: """ + Use 'TestStore.init(initialState:reducer:observe:send:)' to scope a test store's state and actions. + """ + ) public func scope( state toScopedState: @escaping (ScopedState) -> S, action fromScopedAction: @escaping (A) -> ScopedAction @@ -1770,6 +1907,13 @@ extension TestStore { /// - Parameter toScopedState: A function that transforms the reducer's state into scoped state. /// This state will be asserted against as it is mutated by the reducer. Useful for testing view /// store state transformations. + @available( + *, + deprecated, + message: """ + Use 'TestStore.init(initialState:reducer:observe:)' to scope a test store's state. + """ + ) public func scope( state toScopedState: @escaping (ScopedState) -> S ) -> TestStore { From b524b01be3dd7aa0220a4184cc1500ee7cb62ece Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 23 Jan 2023 12:16:04 -0800 Subject: [PATCH 30/38] Unconstrain TestStore action for predicate/case path `receive` (#1856) * Unconstrain TestStore action for predicate/case path `receive` These methods are currently defined in a constrained extension, but it's not necessary, so let's loosen the constraint. * added some tests * Update Sources/ComposableArchitecture/TestStore.swift * flakey test Co-authored-by: Brandon Williams --- .../ComposableArchitecture/TestStore.swift | 209 +++++++++--------- .../ComposableArchitecture/ViewStore.swift | 6 +- .../EffectTests.swift | 48 ++-- .../TestStoreNonExhaustiveTests.swift | 40 ++++ 4 files changed, 175 insertions(+), 128 deletions(-) diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index 57d203ebaf39..5014e84bfd89 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -1333,6 +1333,111 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { ) } + // NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library. + // See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15 + #if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) + /// Asserts an action was received from an effect and asserts how the state changes. + /// + /// When an effect is executed in your feature and sends an action back into the system, you can + /// use this method to assert that fact, and further assert how state changes after the effect + /// action is received: + /// + /// ```swift + /// await store.send(.buttonTapped) + /// await store.receive(.response(.success(42)) { + /// $0.count = 42 + /// } + /// ``` + /// + /// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to + /// pass before effects execute and send actions, and that is why this method suspends. The + /// default time waited is very small, and typically it is enough so you should be controlling + /// your dependencies so that they do not wait for real world time to pass (see + /// for more information on how to do that). + /// + /// To change the amount of time this method waits for an action, pass an explicit `timeout` + /// argument, or set the ``timeout`` on the ``TestStore``. + /// + /// - Parameters: + /// - expectedAction: An action expected from an effect. + /// - duration: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action + /// to the store. The mutable state sent to this closure must be modified to match the state + /// of the store after processing the given action. Do not provide a closure if no change + /// is expected. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @MainActor + public func receive( + _ expectedAction: Action, + timeout duration: Duration, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + await self.receive( + expectedAction, + timeout: duration.nanoseconds, + assert: updateStateToExpectedResult, + file: file, + line: line + ) + } + #endif + + /// Asserts an action was received from an effect and asserts how the state changes. + /// + /// When an effect is executed in your feature and sends an action back into the system, you can + /// use this method to assert that fact, and further assert how state changes after the effect + /// action is received: + /// + /// ```swift + /// await store.send(.buttonTapped) + /// await store.receive(.response(.success(42)) { + /// $0.count = 42 + /// } + /// ``` + /// + /// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to pass + /// before effects execute and send actions, and that is why this method suspends. The default + /// time waited is very small, and typically it is enough so you should be controlling your + /// dependencies so that they do not wait for real world time to pass (see + /// for more information on how to do that). + /// + /// To change the amount of time this method waits for an action, pass an explicit `timeout` + /// argument, or set the ``timeout`` on the ``TestStore``. + /// + /// - Parameters: + /// - expectedAction: An action expected from an effect. + /// - nanoseconds: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to + /// the store. The mutable state sent to this closure must be modified to match the state of + /// the store after processing the given action. Do not provide a closure if no change is + /// expected. + @MainActor + @_disfavoredOverload + public func receive( + _ expectedAction: Action, + timeout nanoseconds: UInt64? = nil, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + guard !self.reducer.inFlightEffects.isEmpty + else { + _ = { + self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line) + }() + return + } + await self.receiveAction(timeout: nanoseconds, file: file, line: line) + _ = { + self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line) + }() + await Task.megaYield() + } +} + +extension TestStore where ScopedState: Equatable { /// Asserts a matching action was received from an effect and asserts how the state changes. /// /// See ``receive(_:timeout:assert:file:line:)-3myco`` for more information of how to use this @@ -1408,53 +1513,6 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { // NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library. // See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15 #if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) - /// Asserts an action was received from an effect and asserts how the state changes. - /// - /// When an effect is executed in your feature and sends an action back into the system, you can - /// use this method to assert that fact, and further assert how state changes after the effect - /// action is received: - /// - /// ```swift - /// await store.send(.buttonTapped) - /// await store.receive(.response(.success(42)) { - /// $0.count = 42 - /// } - /// ``` - /// - /// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to - /// pass before effects execute and send actions, and that is why this method suspends. The - /// default time waited is very small, and typically it is enough so you should be controlling - /// your dependencies so that they do not wait for real world time to pass (see - /// for more information on how to do that). - /// - /// To change the amount of time this method waits for an action, pass an explicit `timeout` - /// argument, or set the ``timeout`` on the ``TestStore``. - /// - /// - Parameters: - /// - expectedAction: An action expected from an effect. - /// - duration: The amount of time to wait for the expected action. - /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action - /// to the store. The mutable state sent to this closure must be modified to match the state - /// of the store after processing the given action. Do not provide a closure if no change - /// is expected. - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) - @MainActor - public func receive( - _ expectedAction: Action, - timeout duration: Duration, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) async { - await self.receive( - expectedAction, - timeout: duration.nanoseconds, - assert: updateStateToExpectedResult, - file: file, - line: line - ) - } - /// Asserts an action was received from an effect that matches a predicate, and asserts how the /// state changes. /// @@ -1507,58 +1565,6 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { } #endif - /// Asserts an action was received from an effect and asserts how the state changes. - /// - /// When an effect is executed in your feature and sends an action back into the system, you can - /// use this method to assert that fact, and further assert how state changes after the effect - /// action is received: - /// - /// ```swift - /// await store.send(.buttonTapped) - /// await store.receive(.response(.success(42)) { - /// $0.count = 42 - /// } - /// ``` - /// - /// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to pass - /// before effects execute and send actions, and that is why this method suspends. The default - /// time waited is very small, and typically it is enough so you should be controlling your - /// dependencies so that they do not wait for real world time to pass (see - /// for more information on how to do that). - /// - /// To change the amount of time this method waits for an action, pass an explicit `timeout` - /// argument, or set the ``timeout`` on the ``TestStore``. - /// - /// - Parameters: - /// - expectedAction: An action expected from an effect. - /// - nanoseconds: The amount of time to wait for the expected action. - /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to - /// the store. The mutable state sent to this closure must be modified to match the state of - /// the store after processing the given action. Do not provide a closure if no change is - /// expected. - @MainActor - @_disfavoredOverload - public func receive( - _ expectedAction: Action, - timeout nanoseconds: UInt64? = nil, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) async { - guard !self.reducer.inFlightEffects.isEmpty - else { - _ = { - self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line) - }() - return - } - await self.receiveAction(timeout: nanoseconds, file: file, line: line) - _ = { - self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line) - }() - await Task.megaYield() - } - /// Asserts an action was received from an effect that matches a predicate, and asserts how the /// state changes. /// @@ -1756,10 +1762,9 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { while let receivedAction = self.reducer.receivedActions.first, !predicate(receivedAction.action) { + self.reducer.receivedActions.removeFirst() actions.append(receivedAction.action) - self.withExhaustivity(.off) { - self.receive(receivedAction.action, file: file, line: line) - } + self.reducer.state = receivedAction.state } if !actions.isEmpty { diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index dd713668c926..51121f943ba0 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -377,7 +377,7 @@ public final class ViewStore: ObservableObject { /// - Parameters: /// - action: An action. /// - predicate: A predicate on `ViewState` that determines for how long this method should - /// suspend. + /// suspend. @MainActor public func send(_ action: ViewAction, while predicate: @escaping (ViewState) -> Bool) async { let task = self.send(action) @@ -396,7 +396,7 @@ public final class ViewStore: ObservableObject { /// - action: An action. /// - animation: The animation to perform when the action is sent. /// - predicate: A predicate on `ViewState` that determines for how long this method should - /// suspend. + /// suspend. @MainActor public func send( _ action: ViewAction, @@ -417,7 +417,7 @@ public final class ViewStore: ObservableObject { /// ``send(_:while:)``. /// /// - Parameter predicate: A predicate on `ViewState` that determines for how long this method - /// should suspend. + /// should suspend. @MainActor public func yield(while predicate: @escaping (ViewState) -> Bool) async { if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { diff --git a/Tests/ComposableArchitectureTests/EffectTests.swift b/Tests/ComposableArchitectureTests/EffectTests.swift index 25be7edd6f75..0fb02cf71101 100644 --- a/Tests/ComposableArchitectureTests/EffectTests.swift +++ b/Tests/ComposableArchitectureTests/EffectTests.swift @@ -307,33 +307,35 @@ final class EffectTests: XCTestCase { } func testDependenciesTransferredToEffects_Run() async { - struct Feature: ReducerProtocol { - enum Action: Equatable { - case tap - case response(Int) - } - @Dependency(\.date) var date - func reduce(into state: inout Int, action: Action) -> EffectTask { - switch action { - case .tap: - return .run { send in - await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) + await _withMainSerialExecutor { + struct Feature: ReducerProtocol { + enum Action: Equatable { + case tap + case response(Int) + } + @Dependency(\.date) var date + func reduce(into state: inout Int, action: Action) -> EffectTask { + switch action { + case .tap: + return .run { send in + await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) + } + case let .response(value): + state = value + return .none } - case let .response(value): - state = value - return .none } } - } - let store = TestStore( - initialState: 0, - reducer: Feature() - .dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890))) - ) + let store = TestStore( + initialState: 0, + reducer: Feature() + .dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890))) + ) - await store.send(.tap).finish(timeout: NSEC_PER_SEC) - await store.receive(.response(1_234_567_890)) { - $0 = 1_234_567_890 + await store.send(.tap).finish(timeout: NSEC_PER_SEC) + await store.receive(.response(1_234_567_890)) { + $0 = 1_234_567_890 + } } } diff --git a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift index e55507667dbe..f4aef9239b5f 100644 --- a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift @@ -582,6 +582,46 @@ } } + func testCasePathReceive_Exhaustive_NonEquatable() async { + struct NonEquatable {} + enum Action { case tap, response(NonEquatable) } + + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + switch action { + case .tap: + return EffectTask(value: .response(NonEquatable())) + case .response: + return .none + } + } + ) + + await store.send(.tap) + await store.receive(/Action.response) + } + + func testPredicateReceive_Exhaustive_NonEquatable() async { + struct NonEquatable {} + enum Action { case tap, response(NonEquatable) } + + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + switch action { + case .tap: + return EffectTask(value: .response(NonEquatable())) + case .response: + return .none + } + } + ) + + await store.send(.tap) + await store.receive({ (/Action.response) ~= $0 }) + } + func testCasePathReceive_SkipReceivedAction() async { let store = TestStore( initialState: NonExhaustiveReceive.State(), From 6f33e07a7a3adba7a0f87c1361b19c4f96ef26f7 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Mon, 23 Jan 2023 16:04:28 -0800 Subject: [PATCH 31/38] Use @StateObject for iOS 15+ alert modifier. (#1860) --- Sources/ComposableArchitecture/SwiftUI/Alert.swift | 2 +- Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ComposableArchitecture/SwiftUI/Alert.swift b/Sources/ComposableArchitecture/SwiftUI/Alert.swift index 0d4b0e833cfa..e5502c958709 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Alert.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Alert.swift @@ -34,7 +34,7 @@ extension View { // NB: Workaround for iOS 14 runtime crashes during iOS 15 availability checks. @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) private struct NewAlertModifier: ViewModifier { - @ObservedObject var viewStore: ViewStore?, Action> + @StateObject var viewStore: ViewStore?, Action> let dismiss: Action func body(content: Content) -> some View { diff --git a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift index dfa91a0e464a..5ba645f29d7c 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift @@ -40,7 +40,7 @@ extension View { // NB: Workaround for iOS 14 runtime crashes during iOS 15 availability checks. @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) private struct NewConfirmationDialogModifier: ViewModifier { - @ObservedObject var viewStore: ViewStore?, Action> + @StateObject var viewStore: ViewStore?, Action> let dismiss: Action func body(content: Content) -> some View { From e294b24edb998a62bdb43878c5972b72370474ab Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 23 Jan 2023 16:41:01 -0800 Subject: [PATCH 32/38] Add `Effect.send` (#1859) * Add `Effect.send` With the `Effect` -> `Effect` migration, `Effect.init(value:)` and `Effect.init(error:)` no longer make sense. We will be retiring the latter some time in the future, so let's also get a head start and rename the former to `Effect.send`. For now it will call `Effect.init(value:)` under the hood, but in the future we will want a non-Combine-driven way of running synchronous effects. * format fix * wip * fix * wip * wip --- .../LoginSwiftUITests/LoginSwiftUITests.swift | 6 +- Makefile | 2 +- .../Articles/Performance.md | 6 +- .../Documentation.docc/Extensions/Effect.md | 1 + .../Extensions/EffectSend.md | 7 ++ Sources/ComposableArchitecture/Effect.swift | 28 +++++++ .../ComposableArchitecture/TestStore.swift | 6 +- .../CompatibilityTests.swift | 2 +- .../ReducerTests.swift | 78 ++++++++++--------- .../TestStoreNonExhaustiveTests.swift | 10 ++- .../ViewStoreTests.swift | 43 +++++----- 11 files changed, 115 insertions(+), 74 deletions(-) create mode 100644 Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectSend.md diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift index 3950f617a9f0..cdbf6815eee5 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/LoginSwiftUITests/LoginSwiftUITests.swift @@ -12,7 +12,7 @@ final class LoginSwiftUITests: XCTestCase { initialState: Login.State(), reducer: Login(), observe: LoginView.ViewState.init, - send: action: Login.Action.init + send: Login.Action.init ) { $0.authenticationClient.login = { _ in AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) @@ -45,7 +45,7 @@ final class LoginSwiftUITests: XCTestCase { initialState: Login.State(), reducer: Login(), observe: LoginView.ViewState.init, - send: action: Login.Action.init + send: Login.Action.init ) { $0.authenticationClient.login = { _ in AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) @@ -82,7 +82,7 @@ final class LoginSwiftUITests: XCTestCase { initialState: Login.State(), reducer: Login(), observe: LoginView.ViewState.init, - send: action: Login.Action.init + send: Login.Action.init ) { $0.authenticationClient.login = { _ in throw AuthenticationError.invalidUserPassword diff --git a/Makefile b/Makefile index 6a7f50ebf052..2702d5d4b661 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ test-examples: for scheme in "CaseStudies (SwiftUI)" "CaseStudies (UIKit)" Integration Search SpeechRecognition TicTacToe Todos VoiceMemos; do \ xcodebuild test \ -scheme "$$scheme" \ - -destination platform="$(PLATFORM_IOS)"; \ + -destination platform="$(PLATFORM_IOS)" || exit 1; \ done benchmark: diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md index 9343da8ee45a..a9b3e585305a 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md @@ -236,15 +236,15 @@ struct Feature: ReducerProtocol { switch action { case .buttonTapped: state.count += 1 - return EffectTask(value: .sharedComputation) + return .send(.sharedComputation) case .toggleChanged: state.isEnabled.toggle() - return EffectTask(value: .sharedComputation) + return .send(.sharedComputation) case let .textFieldChanged(text): state.description = text - return EffectTask(value: .sharedComputation) + return .send(.sharedComputation) case .sharedComputation: // Some shared work to compute something. diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md index a65ca97b82a3..24dbc3e27df5 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md @@ -8,6 +8,7 @@ - ``EffectPublisher/task(priority:operation:catch:file:fileID:line:)`` - ``EffectPublisher/run(priority:operation:catch:file:fileID:line:)`` - ``EffectPublisher/fireAndForget(priority:_:)`` +- ``EffectPublisher/send(_:)`` - ``TaskResult`` ### Cancellation diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectSend.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectSend.md new file mode 100644 index 000000000000..132f94b7f0ac --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectSend.md @@ -0,0 +1,7 @@ +# ``ComposableArchitecture/EffectPublisher/send(_:)`` + +## Topics + +### Animating actions + +- ``EffectPublisher/send(_:animation:)`` diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index 43d677932ab6..0c98a49ed96d 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -323,6 +323,34 @@ extension EffectPublisher where Failure == Never { ) -> Self { Self.run(priority: priority) { _ in try? await work() } } + + /// Initializes an effect that immediately emits the action passed in. + /// + /// > Note: We do not recommend using `Effect.send` to share logic. Instead, limit usage to + /// > child-parent communication, where a child may want to emit a "delegate" action for a parent + /// > to listen to. + /// > + /// > For more information, see . + /// + /// - Parameter action: The action that is immediately emitted by the effect. + public static func send(_ action: Action) -> Self { + Self(value: action) + } + + /// Initializes an effect that immediately emits the action passed in. + /// + /// > Note: We do not recommend using `Effect.send` to share logic. Instead, limit usage to + /// > child-parent communication, where a child may want to emit a "delegate" action for a parent + /// > to listen to. + /// > + /// > For more information, see . + /// + /// - Parameters: + /// - action: The action that is immediately emitted by the effect. + /// - animation: An animation. + public static func send(_ action: Action, animation: Animation? = nil) -> Self { + Self(value: action).animation(animation) + } } /// A type that can send actions back into the system when used from diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index 5014e84bfd89..17ca785b544b 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -1885,7 +1885,8 @@ extension TestStore { @available( *, deprecated, - message: """ + message: + """ Use 'TestStore.init(initialState:reducer:observe:send:)' to scope a test store's state and actions. """ ) @@ -1915,7 +1916,8 @@ extension TestStore { @available( *, deprecated, - message: """ + message: + """ Use 'TestStore.init(initialState:reducer:observe:)' to scope a test store's state. """ ) diff --git a/Tests/ComposableArchitectureTests/CompatibilityTests.swift b/Tests/ComposableArchitectureTests/CompatibilityTests.swift index 2bfe865d7b3b..574e48ac8090 100644 --- a/Tests/ComposableArchitectureTests/CompatibilityTests.swift +++ b/Tests/ComposableArchitectureTests/CompatibilityTests.swift @@ -47,7 +47,7 @@ final class CompatibilityTests: XCTestCase { .cancellable(id: cancelID) case .kickOffAction: - return EffectTask(value: .actionSender(OnDeinit { passThroughSubject.send(.stop) })) + return .send(.actionSender(OnDeinit { passThroughSubject.send(.stop) })) case .actionSender: return .none diff --git a/Tests/ComposableArchitectureTests/ReducerTests.swift b/Tests/ComposableArchitectureTests/ReducerTests.swift index ad10a47e7e88..e86997176fb5 100644 --- a/Tests/ComposableArchitectureTests/ReducerTests.swift +++ b/Tests/ComposableArchitectureTests/ReducerTests.swift @@ -22,56 +22,58 @@ final class ReducerTests: XCTestCase { #if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) func testCombine_EffectsAreMerged() async throws { if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { - enum Action: Equatable { - case increment - } + try await _withMainSerialExecutor { + enum Action: Equatable { + case increment + } - struct Delayed: ReducerProtocol { - typealias State = Int + struct Delayed: ReducerProtocol { + typealias State = Int - @Dependency(\.continuousClock) var clock + @Dependency(\.continuousClock) var clock - let delay: Duration - let setValue: @Sendable () async -> Void + let delay: Duration + let setValue: @Sendable () async -> Void - func reduce(into state: inout State, action: Action) -> EffectTask { - state += 1 - return .fireAndForget { - try await self.clock.sleep(for: self.delay) - await self.setValue() + func reduce(into state: inout State, action: Action) -> EffectTask { + state += 1 + return .fireAndForget { + try await self.clock.sleep(for: self.delay) + await self.setValue() + } } } - } - var fastValue: Int? = nil - var slowValue: Int? = nil + var fastValue: Int? = nil + var slowValue: Int? = nil - let clock = TestClock() + let clock = TestClock() - let store = TestStore( - initialState: 0, - reducer: CombineReducers { - Delayed(delay: .seconds(1), setValue: { @MainActor in fastValue = 42 }) - Delayed(delay: .seconds(2), setValue: { @MainActor in slowValue = 1729 }) + let store = TestStore( + initialState: 0, + reducer: CombineReducers { + Delayed(delay: .seconds(1), setValue: { @MainActor in fastValue = 42 }) + Delayed(delay: .seconds(2), setValue: { @MainActor in slowValue = 1729 }) + } + ) { + $0.continuousClock = clock } - ) { - $0.continuousClock = clock - } - await store.send(.increment) { - $0 = 2 + await store.send(.increment) { + $0 = 2 + } + // Waiting a second causes the fast effect to fire. + await clock.advance(by: .seconds(1)) + try await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) + XCTAssertEqual(fastValue, 42) + XCTAssertEqual(slowValue, nil) + // Waiting one more second causes the slow effect to fire. This proves that the effects + // are merged together, as opposed to concatenated. + await clock.advance(by: .seconds(1)) + await store.finish() + XCTAssertEqual(fastValue, 42) + XCTAssertEqual(slowValue, 1729) } - // Waiting a second causes the fast effect to fire. - await clock.advance(by: .seconds(1)) - try await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) - XCTAssertEqual(fastValue, 42) - XCTAssertEqual(slowValue, nil) - // Waiting one more second causes the slow effect to fire. This proves that the effects - // are merged together, as opposed to concatenated. - await clock.advance(by: .seconds(1)) - await store.finish() - XCTAssertEqual(fastValue, 42) - XCTAssertEqual(slowValue, 1729) } } #endif diff --git a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift index f4aef9239b5f..a2b78ba3c690 100644 --- a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift @@ -584,7 +584,10 @@ func testCasePathReceive_Exhaustive_NonEquatable() async { struct NonEquatable {} - enum Action { case tap, response(NonEquatable) } + enum Action { + case tap + case response(NonEquatable) + } let store = TestStore( initialState: 0, @@ -604,7 +607,10 @@ func testPredicateReceive_Exhaustive_NonEquatable() async { struct NonEquatable {} - enum Action { case tap, response(NonEquatable) } + enum Action { + case tap + case response(NonEquatable) + } let store = TestStore( initialState: 0, diff --git a/Tests/ComposableArchitectureTests/ViewStoreTests.swift b/Tests/ComposableArchitectureTests/ViewStoreTests.swift index b36e311f070e..027318c690c6 100644 --- a/Tests/ComposableArchitectureTests/ViewStoreTests.swift +++ b/Tests/ComposableArchitectureTests/ViewStoreTests.swift @@ -167,33 +167,28 @@ final class ViewStoreTests: XCTestCase { XCTAssertEqual(results, Array(repeating: [0, 1, 2], count: 10).flatMap { $0 }) } - func testSendWhile() { - let expectation = self.expectation(description: "await") - Task { - enum Action { - case response - case tapped - } - let reducer = Reduce { state, action in - switch action { - case .response: - state = false - return .none - case .tapped: - state = true - return .task { .response } - } + func testSendWhile() async { + enum Action { + case response + case tapped + } + let reducer = Reduce { state, action in + switch action { + case .response: + state = false + return .none + case .tapped: + state = true + return .task { .response } } + } - let store = Store(initialState: false, reducer: reducer) - let viewStore = ViewStore(store, observe: { $0 }) + let store = Store(initialState: false, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) - XCTAssertEqual(viewStore.state, false) - await viewStore.send(.tapped, while: { $0 }) - XCTAssertEqual(viewStore.state, false) - expectation.fulfill() - } - self.wait(for: [expectation], timeout: 1) + XCTAssertEqual(viewStore.state, false) + await viewStore.send(.tapped, while: { $0 }) + XCTAssertEqual(viewStore.state, false) } func testSuspend() { From 761ab290f463cada5dc28b9987cfd1ad9e9a7311 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 25 Jan 2023 10:10:32 -0800 Subject: [PATCH 33/38] Improve reducer builder inference and prepare for Swift 5.8 (#1863) * Update builders for changes coming in Swift 5.8 * wip * wip --- .../Internal/Deprecations.swift | 7 + .../AnyReducer/AnyReducerCompatibility.swift | 5 +- .../Reducer/ReducerBuilder.swift | 193 ++++++------------ .../Reducer/Reducers/CombineReducers.swift | 5 +- .../Reducer/Reducers/ForEachReducer.swift | 11 +- .../Reducer/Reducers/IfCaseLetReducer.swift | 11 +- .../Reducer/Reducers/IfLetReducer.swift | 11 +- .../Reducer/Reducers/Scope.swift | 20 +- .../ForEachReducerTests.swift | 4 +- 9 files changed, 108 insertions(+), 159 deletions(-) diff --git a/Sources/ComposableArchitecture/Internal/Deprecations.swift b/Sources/ComposableArchitecture/Internal/Deprecations.swift index 5394fd911eca..9b721db25217 100644 --- a/Sources/ComposableArchitecture/Internal/Deprecations.swift +++ b/Sources/ComposableArchitecture/Internal/Deprecations.swift @@ -5,6 +5,13 @@ import XCTestDynamicOverlay // MARK: - Deprecated after 0.49.2 +@available( + *, + deprecated, + message: "Use 'ReducerBuilder<_, _>' with explicit 'State' and 'Action' generics, instead." +) +public typealias ReducerBuilderOf = ReducerBuilder + // NB: As of Swift 5.7, property wrapper deprecations are not diagnosed, so we may want to keep this // deprecation around for now: // https://github.com/apple/swift/issues/63139 diff --git a/Sources/ComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift b/Sources/ComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift index e87d484bdbbd..6933452d43e7 100644 --- a/Sources/ComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift +++ b/Sources/ComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift @@ -90,8 +90,9 @@ extension Reduce { """ ) extension AnyReducer { - public init(@ReducerBuilderOf _ build: @escaping (Environment) -> R) - where R.State == State, R.Action == Action { + public init( + @ReducerBuilder _ build: @escaping (Environment) -> R + ) where R.State == State, R.Action == Action { self.init { state, action, environment in build(environment).reduce(into: &state, action: action) } diff --git a/Sources/ComposableArchitecture/Reducer/ReducerBuilder.swift b/Sources/ComposableArchitecture/Reducer/ReducerBuilder.swift index b12b05e03324..d7a818fb1c62 100644 --- a/Sources/ComposableArchitecture/Reducer/ReducerBuilder.swift +++ b/Sources/ComposableArchitecture/Reducer/ReducerBuilder.swift @@ -7,101 +7,82 @@ /// See ``CombineReducers`` for an entry point into a reducer builder context. @resultBuilder public enum ReducerBuilder { - #if swift(>=5.7) - @inlinable - public static func buildArray( - _ reducers: [some ReducerProtocol] - ) -> some ReducerProtocol { - _SequenceMany(reducers: reducers) - } - - @inlinable - public static func buildBlock() -> some ReducerProtocol { - EmptyReducer() - } - - @inlinable - public static func buildBlock( - _ reducer: some ReducerProtocol - ) -> some ReducerProtocol { - reducer - } + @inlinable + public static func buildArray(_ reducers: [R]) -> _SequenceMany + where R.State == State, R.Action == Action { + _SequenceMany(reducers: reducers) + } - @inlinable - public static func buildEither( - first reducer: R0 - ) -> _Conditional - where R0.State == State, R0.Action == Action, R1.State == State, R1.Action == Action { - .first(reducer) - } + @inlinable + public static func buildBlock() -> EmptyReducer { + EmptyReducer() + } - @inlinable - public static func buildEither( - second reducer: R1 - ) -> _Conditional - where R0.State == State, R0.Action == Action, R1.State == State, R1.Action == Action { - .second(reducer) - } + @inlinable + public static func buildBlock(_ reducer: R) -> R + where R.State == State, R.Action == Action { + reducer + } - @inlinable - public static func buildExpression( - _ expression: some ReducerProtocol - ) -> some ReducerProtocol { - expression - } + @inlinable + public static func buildEither( + first reducer: R0 + ) -> _Conditional + where R0.State == State, R0.Action == Action, R1.State == State, R1.Action == Action { + .first(reducer) + } - @inlinable - public static func buildFinalResult( - _ reducer: some ReducerProtocol - ) -> some ReducerProtocol { - reducer - } + @inlinable + public static func buildEither( + second reducer: R1 + ) -> _Conditional + where R0.State == State, R0.Action == Action, R1.State == State, R1.Action == Action { + .second(reducer) + } - @inlinable - public static func buildLimitedAvailability( - _ wrapped: some ReducerProtocol - ) -> Reduce { - Reduce(wrapped) - } + @inlinable + public static func buildExpression(_ expression: R) -> R + where R.State == State, R.Action == Action { + expression + } - @inlinable - public static func buildOptional( - _ wrapped: (some ReducerProtocol)? - ) -> some ReducerProtocol { - wrapped - } + @inlinable + public static func buildFinalResult(_ reducer: R) -> R + where R.State == State, R.Action == Action { + reducer + } - @inlinable - public static func buildPartialBlock( - first: some ReducerProtocol - ) -> some ReducerProtocol { - first - } + @inlinable + public static func buildLimitedAvailability( + _ wrapped: R + ) -> Reduce + where R.State == State, R.Action == Action { + Reduce(wrapped) + } - @inlinable - public static func buildPartialBlock( - accumulated: some ReducerProtocol, next: some ReducerProtocol - ) -> some ReducerProtocol { - _Sequence(accumulated, next) - } - #else - @inlinable - public static func buildArray(_ reducers: [R]) -> _SequenceMany - where R.State == State, R.Action == Action { - _SequenceMany(reducers: reducers) - } + @inlinable + public static func buildOptional(_ wrapped: R?) -> R? + where R.State == State, R.Action == Action { + wrapped + } - @inlinable - public static func buildBlock() -> EmptyReducer { - EmptyReducer() - } + @inlinable + public static func buildPartialBlock( + first: R + ) -> R + where R.State == State, R.Action == Action { + first + } - @inlinable - public static func buildBlock(_ reducer: R) -> R - where R.State == State, R.Action == Action { - reducer - } + @inlinable + public static func buildPartialBlock( + accumulated: R0, next: R1 + ) -> _Sequence + where R0.State == State, R0.Action == Action, R1.State == State, R1.Action == Action { + _Sequence(accumulated, next) + } + #if swift(<5.7) @inlinable public static func buildBlock< R0: ReducerProtocol, @@ -330,54 +311,12 @@ public enum ReducerBuilder { ) } - @inlinable - public static func buildEither( - first reducer: R0 - ) -> _Conditional - where R0.State == State, R0.Action == Action { - .first(reducer) - } - - @inlinable - public static func buildEither( - second reducer: R1 - ) -> _Conditional - where R1.State == State, R1.Action == Action { - .second(reducer) - } - - @inlinable - public static func buildExpression(_ expression: R) -> R - where R.State == State, R.Action == Action { - expression - } - - @inlinable - public static func buildFinalResult(_ reducer: R) -> R - where R.State == State, R.Action == Action { - reducer - } - @_disfavoredOverload @inlinable public static func buildFinalResult(_ reducer: R) -> Reduce where R.State == State, R.Action == Action { Reduce(reducer) } - - @inlinable - public static func buildLimitedAvailability( - _ wrapped: R - ) -> Reduce - where R.State == State, R.Action == Action { - Reduce(wrapped) - } - - @inlinable - public static func buildOptional(_ wrapped: R?) -> R? - where R.State == State, R.Action == Action { - wrapped - } #endif public enum _Conditional: ReducerProtocol @@ -440,5 +379,3 @@ public enum ReducerBuilder { } } } - -public typealias ReducerBuilderOf = ReducerBuilder diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/CombineReducers.swift b/Sources/ComposableArchitecture/Reducer/Reducers/CombineReducers.swift index 201a287afc22..62b6b515f873 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/CombineReducers.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/CombineReducers.swift @@ -15,7 +15,8 @@ /// .ifLet(\.child, action: /Action.child) /// } /// ``` -public struct CombineReducers: ReducerProtocol { +public struct CombineReducers: ReducerProtocol +where State == Reducers.State, Action == Reducers.Action { @usableFromInline let reducers: Reducers @@ -24,7 +25,7 @@ public struct CombineReducers: ReducerProtocol { /// - Parameter build: A reducer builder. @inlinable public init( - @ReducerBuilderOf _ build: () -> Reducers + @ReducerBuilder _ build: () -> Reducers ) { self.init(internal: build()) } diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/ForEachReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/ForEachReducer.swift index 21401b07db2c..41500b7c8d10 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/ForEachReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/ForEachReducer.swift @@ -50,14 +50,15 @@ extension ReducerProtocol { /// state. /// - Returns: A reducer that combines the child reducer with the parent reducer. @inlinable - public func forEach( - _ toElementsState: WritableKeyPath>, - action toElementAction: CasePath, - @ReducerBuilderOf _ element: () -> Element, + public func forEach( + _ toElementsState: WritableKeyPath>, + action toElementAction: CasePath, + @ReducerBuilder _ element: () -> Element, file: StaticString = #file, fileID: StaticString = #fileID, line: UInt = #line - ) -> _ForEachReducer { + ) -> _ForEachReducer + where ElementState == Element.State, ElementAction == Element.Action { _ForEachReducer( parent: self, toElementsState: toElementsState, diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift index 9b3284479f58..9ede0aaae8a6 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift @@ -46,14 +46,15 @@ extension ReducerProtocol { /// present /// - Returns: A reducer that combines the child reducer with the parent reducer. @inlinable - public func ifCaseLet( - _ toCaseState: CasePath, - action toCaseAction: CasePath, - @ReducerBuilderOf then case: () -> Case, + public func ifCaseLet( + _ toCaseState: CasePath, + action toCaseAction: CasePath, + @ReducerBuilder then case: () -> Case, file: StaticString = #file, fileID: StaticString = #fileID, line: UInt = #line - ) -> _IfCaseLetReducer { + ) -> _IfCaseLetReducer + where CaseState == Case.State, CaseAction == Case.Action { .init( parent: self, child: `case`(), diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/IfLetReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/IfLetReducer.swift index 115b6b65d17f..9d5e93e770ac 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/IfLetReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/IfLetReducer.swift @@ -43,14 +43,15 @@ extension ReducerProtocol { /// state. /// - Returns: A reducer that combines the child reducer with the parent reducer. @inlinable - public func ifLet( - _ toWrappedState: WritableKeyPath, - action toWrappedAction: CasePath, - @ReducerBuilderOf then wrapped: () -> Wrapped, + public func ifLet( + _ toWrappedState: WritableKeyPath, + action toWrappedAction: CasePath, + @ReducerBuilder then wrapped: () -> Wrapped, file: StaticString = #file, fileID: StaticString = #fileID, line: UInt = #line - ) -> _IfLetReducer { + ) -> _IfLetReducer + where WrappedState == Wrapped.State, WrappedAction == Wrapped.Action { .init( parent: self, child: wrapped(), diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/Scope.swift b/Sources/ComposableArchitecture/Reducer/Reducers/Scope.swift index e2662056ed46..dcd1b442c222 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/Scope.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/Scope.swift @@ -143,11 +143,11 @@ public struct Scope: ReducerP /// - toChildAction: A case path from parent action to a case containing child actions. /// - child: A reducer that will be invoked with child actions against child state. @inlinable - public init( - state toChildState: WritableKeyPath, - action toChildAction: CasePath, - @ReducerBuilderOf _ child: () -> Child - ) { + public init( + state toChildState: WritableKeyPath, + action toChildAction: CasePath, + @ReducerBuilder _ child: () -> Child + ) where ChildState == Child.State, ChildAction == Child.Action { self.init( toChildState: .keyPath(toChildState), toChildAction: toChildAction, @@ -215,14 +215,14 @@ public struct Scope: ReducerP /// - toChildAction: A case path from parent action to a case containing child actions. /// - child: A reducer that will be invoked with child actions against child state. @inlinable - public init( - state toChildState: CasePath, - action toChildAction: CasePath, - @ReducerBuilderOf _ child: () -> Child, + public init( + state toChildState: CasePath, + action toChildAction: CasePath, + @ReducerBuilder _ child: () -> Child, file: StaticString = #file, fileID: StaticString = #fileID, line: UInt = #line - ) { + ) where ChildState == Child.State, ChildAction == Child.Action { self.init( toChildState: .casePath(toChildState, file: file, fileID: fileID, line: line), toChildAction: toChildAction, diff --git a/Tests/ComposableArchitectureTests/ForEachReducerTests.swift b/Tests/ComposableArchitectureTests/ForEachReducerTests.swift index 99a30c393287..5a3f8337be55 100644 --- a/Tests/ComposableArchitectureTests/ForEachReducerTests.swift +++ b/Tests/ComposableArchitectureTests/ForEachReducerTests.swift @@ -85,7 +85,7 @@ struct Elements: ReducerProtocol { } #if swift(>=5.7) var body: some ReducerProtocol { - Reduce { state, action in + Reduce { state, action in .none } .forEach(\.rows, action: /Action.row) { @@ -99,7 +99,7 @@ struct Elements: ReducerProtocol { } #else var body: Reduce { - Reduce { state, action in + Reduce { state, action in .none } .forEach(\.rows, action: /Action.row) { From 63972fa9ea670405011ddc76a09f11e924341fca Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 25 Jan 2023 11:36:18 -0800 Subject: [PATCH 34/38] Update SwiftUI Navigation to support new alert mappings (#1865) * wip * wip * wip * Bump * Update AlertStateUIKit.swift --- .../xcshareddata/swiftpm/Package.resolved | 43 +++++++++++-------- Package.resolved | 27 ++++++++---- Package.swift | 2 +- .../SwiftUI/Alert.swift | 12 +++++- .../SwiftUI/ConfirmationDialog.swift | 12 +++++- .../UIKit/AlertStateUIKit.swift | 15 ++++--- 6 files changed, 73 insertions(+), 38 deletions(-) diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0ecad7b19944..6fa49342688f 100644 --- a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "9f39744e025c7d377987f30b03770805dcb0bcd1", - "version" : "1.1.4" + "revision" : "4ad606ba5d7673ea60679a61ff867cc1ff8c8e86", + "version" : "1.2.1" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "bb436421f57269fbcfe7360735985321585a86e5", - "version" : "0.10.1" + "revision" : "c3a42e8d1a76ff557cf565ed6d8b0aee0e6e75af", + "version" : "0.11.0" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "f504716c27d2e5d4144fa4794b12129301d17729", - "version" : "1.0.3" + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "819d9d370cd721c9d87671e29d947279292e4541", - "version" : "0.6.0" + "revision" : "ead7d30cc224c3642c150b546f4f1080d1c411a8", + "version" : "0.6.1" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "e9e82b5302025092ab8358e794f89a0f0397dd9d", - "version" : "0.1.2" + "revision" : "8282b0c59662eb38946afe30eb403663fc2ecf76", + "version" : "0.1.4" } }, { @@ -77,7 +77,16 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "revision" : "10bc670db657d11bdd561e07de30a9041311b2b1", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" } }, @@ -86,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "bfb0d43e75a15b6dfac770bf33479e8393884a36", - "version" : "0.4.1" + "revision" : "fd34c544ad27f3ba6b19142b348005bfa85b6005", + "version" : "0.6.0" } }, { @@ -95,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "46acf5ecc1cabdb28d7fe03289f6c8b13a023f52", - "version" : "0.4.5" + "revision" : "bf0fb9d53019cbde1a1e0cf290b560a0a0411282", + "version" : "0.6.0" } }, { @@ -104,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "a9daebf0bf65981fd159c885d504481a65a75f02", - "version" : "0.8.0" + "revision" : "16b23a295fa322eb957af98037f86791449de60f", + "version" : "0.8.1" } } ], diff --git a/Package.resolved b/Package.resolved index 8b39bacc9fdb..6fa49342688f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "fddd1c00396eed152c45a46bea9f47b98e59301d", - "version" : "1.2.0" + "revision" : "4ad606ba5d7673ea60679a61ff867cc1ff8c8e86", + "version" : "1.2.1" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "e9e82b5302025092ab8358e794f89a0f0397dd9d", - "version" : "0.1.2" + "revision" : "8282b0c59662eb38946afe30eb403663fc2ecf76", + "version" : "0.1.4" } }, { @@ -77,7 +77,16 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "revision" : "10bc670db657d11bdd561e07de30a9041311b2b1", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" } }, @@ -95,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "ddc01cdcddfd30ef7a966049b2e1d251e224ad93", - "version" : "0.5.0" + "revision" : "bf0fb9d53019cbde1a1e0cf290b560a0a0411282", + "version" : "0.6.0" } }, { @@ -104,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "a9daebf0bf65981fd159c885d504481a65a75f02", - "version" : "0.8.0" + "revision" : "16b23a295fa322eb957af98037f86791449de60f", + "version" : "0.8.1" } } ], diff --git a/Package.swift b/Package.swift index 2b6faad3d695..9105319efc74 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.6.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.1.2"), .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "0.4.1"), - .package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "0.4.5"), + .package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "0.6.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.5.0"), ], targets: [ diff --git a/Sources/ComposableArchitecture/SwiftUI/Alert.swift b/Sources/ComposableArchitecture/SwiftUI/Alert.swift index e5502c958709..3c5c249d0821 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Alert.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Alert.swift @@ -44,7 +44,11 @@ private struct NewAlertModifier: ViewModifier { presenting: viewStore.state, actions: { ForEach($0.buttons) { - Button($0) { viewStore.send($0) } + Button($0) { action in + if let action = action { + viewStore.send(action) + } + } } }, message: { $0.message.map { Text($0) } } @@ -58,7 +62,11 @@ private struct OldAlertModifier: ViewModifier { func body(content: Content) -> some View { content.alert(item: viewStore.binding(send: dismiss)) { state in - Alert(state) { viewStore.send($0) } + Alert(state) { action in + if let action = action { + viewStore.send(action) + } + } } } } diff --git a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift index 5ba645f29d7c..a95e21568e81 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift @@ -51,7 +51,11 @@ private struct NewConfirmationDialogModifier: ViewModifier { presenting: viewStore.state, actions: { ForEach($0.buttons) { - Button($0, action: { viewStore.send($0) }) + Button($0) { action in + if let action = action { + viewStore.send(action) + } + } } }, message: { $0.message.map { Text($0) } } @@ -70,7 +74,11 @@ private struct OldConfirmationDialogModifier: ViewModifier { func body(content: Content) -> some View { #if !os(macOS) return content.actionSheet(item: viewStore.binding(send: dismiss)) { - ActionSheet($0) { viewStore.send($0) } + ActionSheet($0) { action in + if let action = action { + viewStore.send(action) + } + } } #else return EmptyView() diff --git a/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift b/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift index 3987e4f8f4aa..236b4324c8f9 100644 --- a/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift +++ b/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift @@ -43,7 +43,7 @@ /// - send: A function that wraps an alert action in the view store's action type. public convenience init( state: AlertState, - send: @escaping (Action) -> Void + send: @escaping (Action?) -> Void ) { self.init( title: String(state: state.title), @@ -61,7 +61,7 @@ /// - state: The state of dialog that can be shown to the user. /// - send: A function that wraps a dialog action in the view store's action type. public convenience init( - state: ConfirmationDialogState, send: @escaping (Action) -> Void + state: ConfirmationDialogState, send: @escaping (Action?) -> Void ) { self.init( title: String(state: state.title), @@ -80,7 +80,7 @@ @available(tvOS 13, *) @available(watchOS, unavailable) extension UIAlertAction.Style { - init(_ role: ButtonState.Role) { + init(_ role: ButtonStateRole) { switch role { case .cancel: self = .cancel @@ -98,13 +98,14 @@ extension UIAlertAction { convenience init( _ button: ButtonState, - action: @escaping (Action) -> Void + action handler: @escaping (Action?) -> Void ) { self.init( title: String(state: button.label), - style: button.role.map(UIAlertAction.Style.init) ?? .default, - handler: button.action.map { _ in { _ in button.withAction(action) } } - ) + style: button.role.map(UIAlertAction.Style.init) ?? .default + ) { _ in + button.withAction(handler) + } } } #endif From 7cb8776deef91115a2d4a262ea2f5205981276c4 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 26 Jan 2023 08:00:19 -0800 Subject: [PATCH 35/38] Update README.md --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8fa276730f49..f50bb70872b0 100644 --- a/README.md +++ b/README.md @@ -547,13 +547,14 @@ advanced usages. The documentation for releases and `main` are available here: * [`main`](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture) -* [0.49.0](https://pointfreeco.github.io/swift-composable-architecture/0.49.0/documentation/composablearchitecture/) +* [0.50.0](https://pointfreeco.github.io/swift-composable-architecture/0.49.0/documentation/composablearchitecture/)
Other versions - + + * [0.49.0](https://pointfreeco.github.io/swift-composable-architecture/0.49.0/documentation/composablearchitecture/) * [0.48.0](https://pointfreeco.github.io/swift-composable-architecture/0.48.0/documentation/composablearchitecture/) * [0.47.0](https://pointfreeco.github.io/swift-composable-architecture/0.47.0/documentation/composablearchitecture/) * [0.46.0](https://pointfreeco.github.io/swift-composable-architecture/0.46.0/documentation/composablearchitecture/) @@ -561,9 +562,10 @@ The documentation for releases and `main` are available here: * [0.44.0](https://pointfreeco.github.io/swift-composable-architecture/0.44.0/documentation/composablearchitecture/) * [0.43.0](https://pointfreeco.github.io/swift-composable-architecture/0.43.0/documentation/composablearchitecture/) * [0.42.0](https://pointfreeco.github.io/swift-composable-architecture/0.42.0/documentation/composablearchitecture/) - * [0.41.2](https://pointfreeco.github.io/swift-composable-architecture/0.41.0/documentation/composablearchitecture/) - * [0.40.2](https://pointfreeco.github.io/swift-composable-architecture/0.40.0/documentation/composablearchitecture/) + * [0.41.0](https://pointfreeco.github.io/swift-composable-architecture/0.41.0/documentation/composablearchitecture/) + * [0.40.0](https://pointfreeco.github.io/swift-composable-architecture/0.40.0/documentation/composablearchitecture/) * [0.39.0](https://pointfreeco.github.io/swift-composable-architecture/0.39.0/documentation/composablearchitecture/) + * [0.38.0](https://pointfreeco.github.io/swift-composable-architecture/0.38.0/documentation/composablearchitecture/)

From 71bab05171f157f065a3609bb6824ec0bac3db27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=9E=AC=ED=98=B8?= Date: Fri, 27 Jan 2023 02:12:48 +0900 Subject: [PATCH 36/38] Bump up doc version for 0.50.0 release (#1874) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f50bb70872b0..192eca463fce 100644 --- a/README.md +++ b/README.md @@ -547,7 +547,7 @@ advanced usages. The documentation for releases and `main` are available here: * [`main`](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture) -* [0.50.0](https://pointfreeco.github.io/swift-composable-architecture/0.49.0/documentation/composablearchitecture/) +* [0.50.0](https://pointfreeco.github.io/swift-composable-architecture/0.50.0/documentation/composablearchitecture/)
From 1a168e2397981bd54e4c746ba50b92b7b92dfa07 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 26 Jan 2023 09:22:30 -0800 Subject: [PATCH 37/38] Add note to reducer protocol dependency section (#1873) * Add note to reducer protocol dependency section The discussion #1870 noted that our migration guide could include more of a breadcrumb that migrating to the Dependencies library isn't a simple matter of changing every environment property to a `@Dependency` property. * wip Co-authored-by: Brandon Williams --- .../Articles/MigratingToTheReducerProtocol.md | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md index e5d33ff32fda..320f08a7c073 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigratingToTheReducerProtocol.md @@ -492,10 +492,11 @@ But this means that you must explicitly thread all dependencies from the root of through to every child feature. This can be arduous and make it difficult to add, remove or change dependencies. -The library comes with a tool for managing dependencies in a more ergonomic manner, and even comes -with some common dependencies pre-integrated allowing you to access them with no additional work. -For example, the `date` dependency ships with the library so that you can declare your feature's -dependence on that functionality in the following way: +The Composable Architecture now uses the [Dependencies][swift-dependencies] library to manage +dependencies in a more ergonomic manner, and even comes with some common dependencies pre-integrated +allowing you to access them with no additional work. For example, the `date` dependency ships with +the library so that you can declare your feature's dependence on that functionality in the following +way: ```swift struct Feature: ReducerProtocol { @@ -508,6 +509,17 @@ struct Feature: ReducerProtocol { With that one declaration you can stop explicitly passing the date dependency through every layer of your application. A date function will be automatically provided to your feature's reducer. +> Important: [Dependencies][swift-dependencies] is powered by Swift task locals and is intended to +> be used in structured contexts. If your reducer's effects make use of escaping closures, then +> you must do additional work to propagate the dependencies to that context. For example, using +> a dependency from within a Combine operator such as `.map`, `.flatMap` and even `.filter` will +> use the default dependency value. +> +> See the [Dependencies documentation][swift-dependencies-docs] on +> [Dependency lifetimes][swift-dependencies-docs-lifetimes] for more information, and how to +> integrate the `@Dependency` property wrapper into pre-structured concurrency using the +> `withEscapedDependencies` function. + For domain-specific dependencies you can perform a little bit of upfront work to register your dependency with the system, and then it will be automatically available to every layer in your application: @@ -538,6 +550,10 @@ struct Feature: ReducerProtocol { For more information on designing your dependencies and providing live and test dependencies, see our article. +[swift-dependencies]: https://github.com/pointfreeco/swift-dependencies +[swift-dependencies-docs]: https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/ +[swift-dependencies-docs-lifetimes]: https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/lifetimes + ## Stores Stores can be initialized from an initial state and an instance of a type conforming to From 98af2adcb5a6186168a60dd1db834e39a34aa4e1 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jan 2023 13:59:43 -0800 Subject: [PATCH 38/38] `IfLetStore`: ignore view store binding writes to `nil` state (#1879) * `IfLetStore`: ignore view store binding writes to `nil` state * swift-format * wip * add test for filter --------- Co-authored-by: Brandon Williams --- Sources/ComposableArchitecture/Store.swift | 60 +++++++++++++------ .../SwiftUI/IfLetStore.swift | 20 ++++--- .../ComposableArchitecture/ViewStore.swift | 11 +++- .../BindingLocalTests.swift | 40 +++++++++++++ .../StoreTests.swift | 17 ++++++ 5 files changed, 120 insertions(+), 28 deletions(-) create mode 100644 Tests/ComposableArchitectureTests/BindingLocalTests.swift diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 196790a81867..e4b895ecf185 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -307,10 +307,10 @@ public final class Store { self.threadCheck(status: .scope) #if swift(>=5.7) - return self.reducer.rescope(self, state: toChildState, action: fromChildAction) + return self.reducer.rescope(self, state: toChildState, action: { fromChildAction($1) }) #else return (self.scope ?? StoreScope(root: self)) - .rescope(self, state: toChildState, action: fromChildAction) + .rescope(self, state: toChildState, action: { fromChildAction($1) }) #endif } @@ -326,6 +326,19 @@ public final class Store { self.scope(state: toChildState, action: { $0 }) } + @_spi(Internals) public func filter( + _ isSent: @escaping (State, Action) -> Bool + ) -> Store { + self.threadCheck(status: .scope) + + #if swift(>=5.7) + return self.reducer.rescope(self, state: { $0 }, action: { isSent($0, $1) ? $1 : nil }) + #else + return (self.scope ?? StoreScope(root: self)) + .rescope(self, state: { $0 }, action: { isSent($0, $1) ? $1 : nil }) + #endif + } + @_spi(Internals) public func send( _ action: Action, originatingFrom originatingAction: Action? = nil @@ -571,7 +584,7 @@ public typealias StoreOf = Store fileprivate func rescope( _ store: Store, state toChildState: @escaping (State) -> ChildState, - action fromChildAction: @escaping (ChildAction) -> Action + action fromChildAction: @escaping (ChildState, ChildAction) -> Action? ) -> Store { (self as? any AnyScopedReducer ?? ScopedReducer(rootStore: store)) .rescope(store, state: toChildState, action: fromChildAction) @@ -584,7 +597,7 @@ public typealias StoreOf = Store let rootStore: Store let toScopedState: (RootState) -> ScopedState private let parentStores: [Any] - let fromScopedAction: (ScopedAction) -> RootAction + let fromScopedAction: (ScopedState, ScopedAction) -> RootAction? private(set) var isSending = false @inlinable @@ -593,14 +606,14 @@ public typealias StoreOf = Store self.rootStore = rootStore self.toScopedState = { $0 } self.parentStores = [] - self.fromScopedAction = { $0 } + self.fromScopedAction = { $1 } } @inlinable init( rootStore: Store, state toScopedState: @escaping (RootState) -> ScopedState, - action fromScopedAction: @escaping (ScopedAction) -> RootAction, + action fromScopedAction: @escaping (ScopedState, ScopedAction) -> RootAction?, parentStores: [Any] ) { self.rootStore = rootStore @@ -618,7 +631,7 @@ public typealias StoreOf = Store state = self.toScopedState(self.rootStore.state.value) self.isSending = false } - if let task = self.rootStore.send(self.fromScopedAction(action)) { + if let action = self.fromScopedAction(state, action), let task = self.rootStore.send(action) { return .fireAndForget { await task.cancellableValue } } else { return .none @@ -630,7 +643,7 @@ public typealias StoreOf = Store func rescope( _ store: Store, state toRescopedState: @escaping (ScopedState) -> RescopedState, - action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction + action fromRescopedAction: @escaping (RescopedState, RescopedAction) -> ScopedAction? ) -> Store } @@ -639,13 +652,13 @@ public typealias StoreOf = Store func rescope( _ store: Store, state toRescopedState: @escaping (ScopedState) -> RescopedState, - action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction + action fromRescopedAction: @escaping (RescopedState, RescopedAction) -> ScopedAction? ) -> Store { - let fromScopedAction = self.fromScopedAction as! (ScopedAction) -> RootAction + let fromScopedAction = self.fromScopedAction as! (ScopedState, ScopedAction) -> RootAction? let reducer = ScopedReducer( rootStore: self.rootStore, state: { _ in toRescopedState(store.state.value) }, - action: { fromScopedAction(fromRescopedAction($0)) }, + action: { fromRescopedAction($0, $1).flatMap { fromScopedAction(store.state.value, $0) } }, parentStores: self.parentStores + [store] ) let childStore = Store( @@ -666,7 +679,7 @@ public typealias StoreOf = Store func rescope( _ store: Store, state toRescopedState: @escaping (ScopedState) -> RescopedState, - action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction + action fromRescopedAction: @escaping (RescopedState, RescopedAction) -> ScopedAction? ) -> Store } @@ -675,12 +688,15 @@ public typealias StoreOf = Store let fromScopedAction: Any init(root: Store) { - self.init(root: root, fromScopedAction: { $0 }) + self.init( + root: root, + fromScopedAction: { (state: RootState, action: RootAction) -> RootAction? in action } + ) } - private init( + private init( root: Store, - fromScopedAction: @escaping (ScopedAction) -> RootAction + fromScopedAction: @escaping (ScopedState, ScopedAction) -> RootAction? ) { self.root = root self.fromScopedAction = fromScopedAction @@ -689,9 +705,9 @@ public typealias StoreOf = Store func rescope( _ scopedStore: Store, state toRescopedState: @escaping (ScopedState) -> RescopedState, - action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction + action fromRescopedAction: @escaping (RescopedState, RescopedAction) -> ScopedAction? ) -> Store { - let fromScopedAction = self.fromScopedAction as! (ScopedAction) -> RootAction + let fromScopedAction = self.fromScopedAction as! (ScopedState, ScopedAction) -> RootAction? var isSending = false let rescopedStore = Store( @@ -699,7 +715,11 @@ public typealias StoreOf = Store reducer: .init { rescopedState, rescopedAction, _ in isSending = true defer { isSending = false } - let task = self.root.send(fromScopedAction(fromRescopedAction(rescopedAction))) + guard + let scopedAction = fromRescopedAction(rescopedState, rescopedAction), + let rootAction = fromScopedAction(scopedStore.state.value, scopedAction) + else { return .none } + let task = self.root.send(rootAction) rescopedState = toRescopedState(scopedStore.state.value) if let task = task { return .fireAndForget { await task.cancellableValue } @@ -717,7 +737,9 @@ public typealias StoreOf = Store } rescopedStore.scope = StoreScope( root: self.root, - fromScopedAction: { fromScopedAction(fromRescopedAction($0)) } + fromScopedAction: { + fromRescopedAction($0, $1).flatMap { fromScopedAction(scopedStore.state.value, $0) } + } ) return rescopedStore } diff --git a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift index fbde7cae5167..5db2f923802f 100644 --- a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift @@ -57,10 +57,12 @@ public struct IfLetStore: View { if var state = viewStore.state { return ViewBuilder.buildEither( first: ifContent( - store.scope { - state = $0 ?? state - return state - } + store + .filter { state, _ in state == nil ? !BindingLocal.isActive : true } + .scope { + state = $0 ?? state + return state + } ) ) } else { @@ -84,10 +86,12 @@ public struct IfLetStore: View { self.content = { viewStore in if var state = viewStore.state { return ifContent( - store.scope { - state = $0 ?? state - return state - } + store + .filter { state, _ in state == nil ? !BindingLocal.isActive : true } + .scope { + state = $0 ?? state + return state + } ) } else { return nil diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index 51121f943ba0..26ea596ff902 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -447,6 +447,7 @@ public final class ViewStore: ObservableObject { } } } + /// Derives a binding from the store that prevents direct writes to state and instead sends /// actions to the store. /// @@ -577,7 +578,11 @@ public final class ViewStore: ObservableObject { send action: HashableWrapper<(Value) -> ViewAction> ) -> Value { get { state.rawValue(self.state) } - set { self.send(action.rawValue(newValue)) } + set { + BindingLocal.$isActive.withValue(true) { + self.send(action.rawValue(newValue)) + } + } } } @@ -767,3 +772,7 @@ private struct HashableWrapper: Hashable { static func == (lhs: Self, rhs: Self) -> Bool { false } func hash(into hasher: inout Hasher) {} } + +enum BindingLocal { + @TaskLocal static var isActive = false +} diff --git a/Tests/ComposableArchitectureTests/BindingLocalTests.swift b/Tests/ComposableArchitectureTests/BindingLocalTests.swift new file mode 100644 index 000000000000..62a4326e57d6 --- /dev/null +++ b/Tests/ComposableArchitectureTests/BindingLocalTests.swift @@ -0,0 +1,40 @@ +#if DEBUG + import XCTest + + @testable import ComposableArchitecture + + @MainActor + final class BindingLocalTests: XCTestCase { + public func testBindingLocalIsActive() { + XCTAssertFalse(BindingLocal.isActive) + + struct MyReducer: ReducerProtocol { + struct State: Equatable { + var text = "" + } + + enum Action: Equatable { + case textChanged(String) + } + + func reduce(into state: inout State, action: Action) -> EffectTask { + switch action { + case let .textChanged(text): + state.text = text + return .none + } + } + } + + let store = Store(initialState: MyReducer.State(), reducer: MyReducer()) + let viewStore = ViewStore(store, observe: { $0 }) + + let binding = viewStore.binding(get: \.text) { text in + XCTAssertTrue(BindingLocal.isActive) + return .textChanged(text) + } + binding.wrappedValue = "Hello!" + XCTAssertEqual(viewStore.text, "Hello!") + } + } +#endif diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index 2c41a3afb092..b88fd23a8e90 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -559,4 +559,21 @@ final class StoreTests: XCTestCase { XCTAssertEqual(viewStore.state, UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) } + + func testFilter() { + let store = Store(initialState: nil, reducer: EmptyReducer()) + .filter { state, _ in state != nil } + + let viewStore = ViewStore(store) + var count = 0 + viewStore.publisher + .sink { _ in count += 1 } + .store(in: &self.cancellables) + + XCTAssertEqual(count, 1) + viewStore.send(()) + XCTAssertEqual(count, 1) + viewStore.send(()) + XCTAssertEqual(count, 1) + } }