From 1a89dbd78249f34070241558d6c75e31f75d47be Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 26 Jan 2023 08:00:19 -0800 Subject: [PATCH 1/8] Update README.md (cherry picked from commit 7cb8776deef91115a2d4a262ea2f5205981276c4) --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 707bef61a..b266f54e7 100644 --- a/README.md +++ b/README.md @@ -563,13 +563,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/) @@ -577,9 +578,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 dea27722dae9a7d9f7d1d8a41418d54137bc7949 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 2/8] Bump up doc version for 0.50.0 release (#1874) (cherry picked from commit 71bab05171f157f065a3609bb6824ec0bac3db27) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b266f54e7..c45b04c06 100644 --- a/README.md +++ b/README.md @@ -563,7 +563,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 49d09bdaa181e1c5e8b8a4c17f4aa0f6e1b7d54c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 26 Jan 2023 09:22:30 -0800 Subject: [PATCH 3/8] 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 (cherry picked from commit 1a168e2397981bd54e4c746ba50b92b7b92dfa07) --- .../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 e5d33ff32..320f08a7c 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 c1acd016a4228edb0c03251d17938dbf7bdffd31 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jan 2023 13:59:43 -0800 Subject: [PATCH 4/8] `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 (cherry picked from commit 98af2adcb5a6186168a60dd1db834e39a34aa4e1) # Conflicts: # Sources/ComposableArchitecture/Store.swift # Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift # Sources/ComposableArchitecture/ViewStore.swift # Tests/ComposableArchitectureTests/StoreTests.swift --- Sources/ComposableArchitecture/Store.swift | 72 +- .../SwiftUI/IfLetStore.swift | 192 ++-- .../ComposableArchitecture/ViewStore.swift | 567 ++++++------ .../BindingLocalTests.swift | 40 + .../StoreTests.swift | 871 +++++++++--------- 5 files changed, 917 insertions(+), 825 deletions(-) create mode 100644 Tests/ComposableArchitectureTests/BindingLocalTests.swift diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 051516404..aa5ed2c8d 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -315,10 +315,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 } @@ -334,6 +334,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 @@ -386,16 +399,16 @@ public final class Store { } }, completed: { [weak self] in - self?.threadCheck(status: .effectCompletion(action)) - boxedTask.wrappedValue?.cancel() - didComplete = true + self?.threadCheck(status: .effectCompletion(action)) + boxedTask.wrappedValue?.cancel() + didComplete = true self?.effectDisposables.removeValue(forKey: uuid)?.dispose() - }, + }, interrupted: { [weak self] in boxedTask.wrappedValue?.cancel() didComplete = true self?.effectDisposables.removeValue(forKey: uuid)?.dispose() - } + } ) let effectDisposable = CompositeDisposable() @@ -403,7 +416,7 @@ public final class Store { effectDisposable += AnyDisposable { [weak self] in self?.threadCheck(status: .effectCompletion(action)) self?.effectDisposables.removeValue(forKey: uuid)?.dispose() - } + } if !didComplete { let task = Task { @MainActor in @@ -590,7 +603,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) @@ -603,7 +616,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 @@ -612,14 +625,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 @@ -637,7 +650,7 @@ public typealias StoreOf = Store state = self.toScopedState(self.rootStore.state) 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 @@ -649,7 +662,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 } @@ -658,13 +671,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) }, - action: { fromScopedAction(fromRescopedAction($0)) }, + action: { fromRescopedAction($0, $1).flatMap { fromScopedAction(store.state.value, $0) } }, parentStores: self.parentStores + [store] ) let childStore = Store( @@ -685,7 +698,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 } @@ -694,12 +707,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 @@ -708,9 +724,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( @@ -718,7 +734,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) if let task = task { return .fireAndForget { await task.cancellableValue } @@ -736,7 +756,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 9cf298246..467e029b8 100644 --- a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift @@ -1,108 +1,112 @@ #if canImport(SwiftUI) - import SwiftUI +import SwiftUI - /// A view that safely unwraps a store of optional state in order to show one of two views. - /// - /// When the underlying state is non-`nil`, the `then` closure will be performed with a ``Store`` - /// that holds onto non-optional state, and otherwise the `else` closure will be performed. - /// - /// This is useful for deciding between two views to show depending on an optional piece of state: - /// - /// ```swift - /// IfLetStore( - /// store.scope(state: \SearchState.results, action: SearchAction.results), - /// ) { - /// SearchResultsView(store: $0) - /// } else: { - /// Text("Loading search results...") - /// } - /// ``` - /// - /// And for showing a sheet when a piece of state becomes non-`nil`: - /// - /// ```swift - /// .sheet( - /// isPresented: viewStore.binding( - /// get: \.isGameActive, - /// send: { $0 ? .startButtonTapped : .detailDismissed } - /// ) - /// ) { - /// IfLetStore( - /// self.store.scope(state: \.detail, action: AppAction.detail) - /// ) { - /// DetailView(store: $0) - /// } - /// } - /// ``` - /// +/// A view that safely unwraps a store of optional state in order to show one of two views. +/// +/// When the underlying state is non-`nil`, the `then` closure will be performed with a ``Store`` +/// that holds onto non-optional state, and otherwise the `else` closure will be performed. +/// +/// This is useful for deciding between two views to show depending on an optional piece of state: +/// +/// ```swift +/// IfLetStore( +/// store.scope(state: \SearchState.results, action: SearchAction.results), +/// ) { +/// SearchResultsView(store: $0) +/// } else: { +/// Text("Loading search results...") +/// } +/// ``` +/// +/// And for showing a sheet when a piece of state becomes non-`nil`: +/// +/// ```swift +/// .sheet( +/// isPresented: viewStore.binding( +/// get: \.isGameActive, +/// send: { $0 ? .startButtonTapped : .detailDismissed } +/// ) +/// ) { +/// IfLetStore( +/// self.store.scope(state: \.detail, action: AppAction.detail) +/// ) { +/// DetailView(store: $0) +/// } +/// } +/// ``` +/// public struct IfLetStore: View where Content: View { - private let content: (ViewStore) -> Content - private let store: Store - - /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional - /// state is `nil` or non-`nil`. - /// - /// - Parameters: - /// - store: A store of optional state. - /// - ifContent: A function that is given a store of non-optional state and returns a view that - /// is visible only when the optional state is non-`nil`. - /// - elseContent: A view that is only visible when the optional state is `nil`. - public init( - _ store: Store, - @ViewBuilder then ifContent: @escaping (Store) -> IfContent, - @ViewBuilder else elseContent: () -> ElseContent - ) where Content == _ConditionalContent { - self.store = store - let elseContent = elseContent() - self.content = { viewStore in - if var state = viewStore.state { - return ViewBuilder.buildEither( - first: ifContent( - store.scope { - state = $0 ?? state - return state - } - ) - ) - } else { - return ViewBuilder.buildEither(second: elseContent) - } - } - } + private let content: (ViewStore) -> Content + private let store: Store - /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional - /// state is `nil` or non-`nil`. - /// - /// - Parameters: - /// - store: A store of optional state. - /// - ifContent: A function that is given a store of non-optional state and returns a view that - /// is visible only when the optional state is non-`nil`. - public init( - _ store: Store, - @ViewBuilder then ifContent: @escaping (Store) -> IfContent - ) where Content == IfContent? { - self.store = store - self.content = { viewStore in - if var state = viewStore.state { - return ifContent( - store.scope { + /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional + /// state is `nil` or non-`nil`. + /// + /// - Parameters: + /// - store: A store of optional state. + /// - ifContent: A function that is given a store of non-optional state and returns a view that + /// is visible only when the optional state is non-`nil`. + /// - elseContent: A view that is only visible when the optional state is `nil`. + public init( + _ store: Store, + @ViewBuilder then ifContent: @escaping (Store) -> IfContent, + @ViewBuilder else elseContent: () -> ElseContent + ) where Content == _ConditionalContent { + self.store = store + let elseContent = elseContent() + self.content = { viewStore in + if var state = viewStore.state { + return ViewBuilder.buildEither( + first: ifContent( + store + .filter { state, _ in state == nil ? !BindingLocal.isActive : true } + .scope { state = $0 ?? state return state } ) - } else { - return nil - } + ) + } else { + return ViewBuilder.buildEither(second: elseContent) } } + } - public var body: some View { - WithViewStore( - self.store, - observe: { $0 }, - removeDuplicates: { ($0 != nil) == ($1 != nil) }, - content: self.content - ) + /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional + /// state is `nil` or non-`nil`. + /// + /// - Parameters: + /// - store: A store of optional state. + /// - ifContent: A function that is given a store of non-optional state and returns a view that + /// is visible only when the optional state is non-`nil`. + public init( + _ store: Store, + @ViewBuilder then ifContent: @escaping (Store) -> IfContent + ) where Content == IfContent? { + self.store = store + self.content = { viewStore in + if var state = viewStore.state { + return ifContent( + store + .filter { state, _ in state == nil ? !BindingLocal.isActive : true } + .scope { + state = $0 ?? state + return state + } + ) + } else { + return nil + } } } + + public var body: some View { + WithViewStore( + self.store, + observe: { $0 }, + removeDuplicates: { ($0 != nil) == ($1 != nil) }, + content: self.content + ) + } +} #endif diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index 50fd6c79b..49e65c065 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -1,10 +1,10 @@ import ReactiveSwift #if canImport(Combine) - import Combine +import Combine #endif #if canImport(SwiftUI) - import SwiftUI +import SwiftUI #endif /// A `ViewStore` is an object that can observe state changes and send actions. They are most @@ -108,7 +108,7 @@ public final class ViewStore { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in guard let objectWillChange = objectWillChange, let _state = _state else { return } #if canImport(Combine) - objectWillChange.send() + objectWillChange.send() #endif _state.value = $0 } @@ -144,7 +144,7 @@ public final class ViewStore { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in guard let objectWillChange = objectWillChange, let _state = _state else { return } #if canImport(Combine) - objectWillChange.send() + objectWillChange.send() #endif _state.value = $0 } @@ -224,7 +224,7 @@ public final class ViewStore { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in guard let objectWillChange = objectWillChange, let _state = _state else { return } #if canImport(Combine) - objectWillChange.send() + objectWillChange.send() #endif _state.value = $0 } @@ -297,302 +297,303 @@ public final class ViewStore { } #if canImport(SwiftUI) - /// Sends an action to the store with a given animation. - /// - /// See ``ViewStore/send(_:)`` for more info. - /// - /// - Parameters: - /// - action: An action. - /// - animation: An animation. - @discardableResult - public func send(_ action: ViewAction, animation: Animation?) -> ViewStoreTask { - send(action, transaction: Transaction(animation: animation)) - } + /// Sends an action to the store with a given animation. + /// + /// See ``ViewStore/send(_:)`` for more info. + /// + /// - Parameters: + /// - action: An action. + /// - animation: An animation. + @discardableResult + 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. - /// - /// - Parameters: - /// - action: An action. - /// - transaction: A transaction. - @discardableResult - public func send(_ action: ViewAction, transaction: Transaction) -> ViewStoreTask { - withTransaction(transaction) { - self.send(action) - } + /// 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) } + } #endif #if canImport(_Concurrency) && compiler(>=5.5.2) - /// Sends an action into the store and then suspends while a piece of state is `true`. - /// - /// This method can be used to interact with async/await code, allowing you to suspend while work - /// is being performed in an effect. One common example of this is using SwiftUI's `.refreshable` - /// method, which shows a loading indicator on the screen while work is being performed. - /// - /// For example, suppose we wanted to load some data from the network when a pull-to-refresh - /// gesture is performed on a list. The domain and logic for this feature can be modeled like so: - /// - /// ```swift - /// struct Feature: ReducerProtocol { - /// struct State: Equatable { - /// var isLoading = false - /// var response: String? - /// } - /// enum Action { - /// case pulledToRefresh - /// case receivedResponse(TaskResult) - /// } - /// @Dependency(\.fetch) var fetch - /// - /// func reduce(into state: inout State, action: Action) -> EffectTask { - /// switch action { - /// case .pulledToRefresh: - /// state.isLoading = true - /// return .task { - /// await .receivedResponse(TaskResult { try await self.fetch() }) - /// } - /// - /// case let .receivedResponse(result): - /// state.isLoading = false - /// state.response = try? result.value - /// return .none - /// } - /// } - /// } - /// ``` - /// - /// Note that we keep track of an `isLoading` boolean in our state so that we know exactly when - /// the network response is being performed. - /// - /// The view can show the fact in a `List`, if it's present, and we can use the `.refreshable` - /// view modifier to enhance the list with pull-to-refresh capabilities: - /// - /// ```swift - /// struct MyView: View { - /// let store: Store - /// - /// var body: some View { - /// WithViewStore(self.store, observe: { $0 }) { viewStore in - /// List { - /// if let response = viewStore.response { - /// Text(response) - /// } - /// } - /// .refreshable { - /// await viewStore.send(.pulledToRefresh, while: \.isLoading) - /// } - /// } - /// } - /// } - /// ``` - /// - /// Here we've used the ``send(_:while:)`` method to suspend while the `isLoading` state is - /// `true`. Once that piece of state flips back to `false` the method will resume, signaling to - /// `.refreshable` that the work has finished which will cause the loading indicator to disappear. - /// - /// - Parameters: - /// - action: An action. - /// - predicate: A predicate on `ViewState` that determines for how long this method should - /// suspend. - @MainActor - public func send(_ action: ViewAction, while predicate: @escaping (ViewState) -> Bool) async { - let task = self.send(action) - await withTaskCancellationHandler { - await self.yield(while: predicate) - } onCancel: { - task.rawValue?.cancel() - } + /// Sends an action into the store and then suspends while a piece of state is `true`. + /// + /// This method can be used to interact with async/await code, allowing you to suspend while work + /// is being performed in an effect. One common example of this is using SwiftUI's `.refreshable` + /// method, which shows a loading indicator on the screen while work is being performed. + /// + /// For example, suppose we wanted to load some data from the network when a pull-to-refresh + /// gesture is performed on a list. The domain and logic for this feature can be modeled like so: + /// + /// ```swift + /// struct Feature: ReducerProtocol { + /// struct State: Equatable { + /// var isLoading = false + /// var response: String? + /// } + /// enum Action { + /// case pulledToRefresh + /// case receivedResponse(TaskResult) + /// } + /// @Dependency(\.fetch) var fetch + /// + /// func reduce(into state: inout State, action: Action) -> EffectTask { + /// switch action { + /// case .pulledToRefresh: + /// state.isLoading = true + /// return .task { + /// await .receivedResponse(TaskResult { try await self.fetch() }) + /// } + /// + /// case let .receivedResponse(result): + /// state.isLoading = false + /// state.response = try? result.value + /// return .none + /// } + /// } + /// } + /// ``` + /// + /// Note that we keep track of an `isLoading` boolean in our state so that we know exactly when + /// the network response is being performed. + /// + /// The view can show the fact in a `List`, if it's present, and we can use the `.refreshable` + /// view modifier to enhance the list with pull-to-refresh capabilities: + /// + /// ```swift + /// struct MyView: View { + /// let store: Store + /// + /// var body: some View { + /// WithViewStore(self.store, observe: { $0 }) { viewStore in + /// List { + /// if let response = viewStore.response { + /// Text(response) + /// } + /// } + /// .refreshable { + /// await viewStore.send(.pulledToRefresh, while: \.isLoading) + /// } + /// } + /// } + /// } + /// ``` + /// + /// Here we've used the ``send(_:while:)`` method to suspend while the `isLoading` state is + /// `true`. Once that piece of state flips back to `false` the method will resume, signaling to + /// `.refreshable` that the work has finished which will cause the loading indicator to disappear. + /// + /// - Parameters: + /// - action: An action. + /// - predicate: A predicate on `ViewState` that determines for how long this method should + /// suspend. + @MainActor + public func send(_ action: ViewAction, while predicate: @escaping (ViewState) -> Bool) async { + let task = self.send(action) + await withTaskCancellationHandler { + await self.yield(while: predicate) + } onCancel: { + task.rawValue?.cancel() } + } #if canImport(SwiftUI) - /// Sends an action into the store and then suspends while a piece of state is `true`. - /// - /// See the documentation of ``send(_:while:)`` for more information. - /// - /// - Parameters: - /// - 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. - @MainActor - public func send( - _ action: ViewAction, - animation: Animation?, - while predicate: @escaping (ViewState) -> Bool - ) async { - let task = withAnimation(animation) { self.send(action) } - await withTaskCancellationHandler { - await self.yield(while: predicate) - } onCancel: { - task.rawValue?.cancel() - } - } + /// Sends an action into the store and then suspends while a piece of state is `true`. + /// + /// See the documentation of ``send(_:while:)`` for more information. + /// + /// - Parameters: + /// - 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. + @MainActor + public func send( + _ action: ViewAction, + animation: Animation?, + while predicate: @escaping (ViewState) -> Bool + ) async { + let task = withAnimation(animation) { self.send(action) } + await withTaskCancellationHandler { + await self.yield(while: predicate) + } onCancel: { + task.rawValue?.cancel() + } + } #endif - /// Suspends the current task while a predicate on state is `true`. - /// - /// If you want to suspend at the same time you send an action to the view store, use - /// ``send(_:while:)``. - /// - /// - Parameter predicate: A predicate on `ViewState` that determines for how long this method - /// should suspend. - @MainActor - public func yield(while predicate: @escaping (ViewState) -> Bool) async { - if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + /// Suspends the current task while a predicate on state is `true`. + /// + /// If you want to suspend at the same time you send an action to the view store, use + /// ``send(_:while:)``. + /// + /// - Parameter predicate: A predicate on `ViewState` that determines for how long this method + /// should suspend. + @MainActor + public func yield(while predicate: @escaping (ViewState) -> Bool) async { + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { _ = await self.produced.producer - .values - .first(where: { !predicate($0) }) - } else { + .values + .first(where: { !predicate($0) }) + } else { let cancellable = Box(wrappedValue: nil) - try? await withTaskCancellationHandler { - try Task.checkCancellation() - try await withUnsafeThrowingContinuation { - (continuation: UnsafeContinuation) in - guard !Task.isCancelled else { - continuation.resume(throwing: CancellationError()) - return - } + try? await withTaskCancellationHandler { + try Task.checkCancellation() + try await withUnsafeThrowingContinuation { + (continuation: UnsafeContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } cancellable.wrappedValue = self.produced.producer - .filter { !predicate($0) } + .filter { !predicate($0) } .take(first: 1) .startWithValues { _ in - continuation.resume() - _ = cancellable - } - } - } onCancel: { - cancellable.wrappedValue?.dispose() + continuation.resume() + _ = cancellable + } } + } onCancel: { + cancellable.wrappedValue?.dispose() } } + } #endif #if canImport(SwiftUI) - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, a text field binding can be created like this: - /// - /// ```swift - /// struct State { var name = "" } - /// enum Action { case nameChanged(String) } - /// - /// TextField( - /// "Enter name", - /// text: viewStore.binding( - /// get: { $0.name }, - /// send: { Action.nameChanged($0) } - /// ) - /// ) - /// ``` - /// - /// - Parameters: - /// - get: A function to get the state for the binding from the view store's full state. - /// - valueToAction: A function that transforms the binding's value into an action that can be - /// sent to the store. - /// - Returns: A binding. - public func binding( - get: @escaping (ViewState) -> Value, - send valueToAction: @escaping (Value) -> ViewAction - ) -> Binding { - ObservedObject(wrappedValue: self) - .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] - } - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, an alert binding can be dealt with like this: - /// - /// ```swift - /// struct State { var alert: String? } - /// enum Action { case alertDismissed } - /// - /// .alert( - /// item: self.store.binding( - /// get: { $0.alert }, - /// send: .alertDismissed - /// ) - /// ) { alert in Alert(title: Text(alert.message)) } - /// ``` - /// - /// - Parameters: - /// - get: A function to get the state for the binding from the view store's full state. - /// - action: The action to send when the binding is written to. - /// - Returns: A binding. - public func binding( - get: @escaping (ViewState) -> Value, - send action: ViewAction - ) -> Binding { - self.binding(get: get, send: { _ in action }) - } - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, a text field binding can be created like this: - /// - /// ```swift - /// typealias State = String - /// enum Action { case nameChanged(String) } - /// - /// TextField( - /// "Enter name", - /// text: viewStore.binding( - /// send: { Action.nameChanged($0) } - /// ) - /// ) - /// ``` - /// - /// - Parameters: - /// - valueToAction: A function that transforms the binding's value into an action that can be - /// sent to the store. - /// - Returns: A binding. - public func binding( - send valueToAction: @escaping (ViewState) -> ViewAction - ) -> Binding { - self.binding(get: { $0 }, send: valueToAction) - } + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, a text field binding can be created like this: + /// + /// ```swift + /// struct State { var name = "" } + /// enum Action { case nameChanged(String) } + /// + /// TextField( + /// "Enter name", + /// text: viewStore.binding( + /// get: { $0.name }, + /// send: { Action.nameChanged($0) } + /// ) + /// ) + /// ``` + /// + /// - Parameters: + /// - get: A function to get the state for the binding from the view store's full state. + /// - valueToAction: A function that transforms the binding's value into an action that can be + /// sent to the store. + /// - Returns: A binding. + public func binding( + get: @escaping (ViewState) -> Value, + send valueToAction: @escaping (Value) -> ViewAction + ) -> Binding { + ObservedObject(wrappedValue: self) + .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] + } + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, an alert binding can be dealt with like this: + /// + /// ```swift + /// struct State { var alert: String? } + /// enum Action { case alertDismissed } + /// + /// .alert( + /// item: self.store.binding( + /// get: { $0.alert }, + /// send: .alertDismissed + /// ) + /// ) { alert in Alert(title: Text(alert.message)) } + /// ``` + /// + /// - Parameters: + /// - get: A function to get the state for the binding from the view store's full state. + /// - action: The action to send when the binding is written to. + /// - Returns: A binding. + public func binding( + get: @escaping (ViewState) -> Value, + send action: ViewAction + ) -> Binding { + self.binding(get: get, send: { _ in action }) + } - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, an alert binding can be dealt with like this: - /// - /// ```swift - /// typealias State = String - /// enum Action { case alertDismissed } - /// - /// .alert( - /// item: viewStore.binding( - /// send: .alertDismissed - /// ) - /// ) { title in Alert(title: Text(title)) } - /// ``` - /// - /// - Parameters: - /// - action: The action to send when the binding is written to. - /// - Returns: A binding. - public func binding(send action: ViewAction) -> Binding { - self.binding(send: { _ in action }) - } + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, a text field binding can be created like this: + /// + /// ```swift + /// typealias State = String + /// enum Action { case nameChanged(String) } + /// + /// TextField( + /// "Enter name", + /// text: viewStore.binding( + /// send: { Action.nameChanged($0) } + /// ) + /// ) + /// ``` + /// + /// - Parameters: + /// - valueToAction: A function that transforms the binding's value into an action that can be + /// sent to the store. + /// - Returns: A binding. + public func binding( + send valueToAction: @escaping (ViewState) -> ViewAction + ) -> Binding { + self.binding(get: { $0 }, send: valueToAction) + } + + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, an alert binding can be dealt with like this: + /// + /// ```swift + /// typealias State = String + /// enum Action { case alertDismissed } + /// + /// .alert( + /// item: viewStore.binding( + /// send: .alertDismissed + /// ) + /// ) { title in Alert(title: Text(title)) } + /// ``` + /// + /// - Parameters: + /// - action: The action to send when the binding is written to. + /// - Returns: A binding. + public func binding(send action: ViewAction) -> Binding { + self.binding(send: { _ in action }) + } #endif private subscript( @@ -600,7 +601,11 @@ public final class ViewStore { 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)) + } + } } } @@ -790,3 +795,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 000000000..62a4326e5 --- /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 7e6b1fad6..9940ea32a 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -4,8 +4,8 @@ import XCTest // `@MainActor` introduces issues gathering tests on Linux #if !os(Linux) - @MainActor - final class StoreTests: XCTestCase { +@MainActor +final class StoreTests: XCTestCase { func testProducedMapping() { struct ChildState: Equatable { @@ -39,554 +39,571 @@ import XCTest } #if DEBUG - func testCancellableIsRemovedOnImmediatelyCompletingEffect() { - let store = Store(initialState: (), reducer: EmptyReducer()) + func testCancellableIsRemovedOnImmediatelyCompletingEffect() { + let store = Store(initialState: (), reducer: EmptyReducer()) XCTAssertEqual(store.effectDisposables.count, 0) - _ = store.send(()) + _ = store.send(()) XCTAssertEqual(store.effectDisposables.count, 0) - } + } #endif - func testCancellableIsRemovedWhenEffectCompletes() { + func testCancellableIsRemovedWhenEffectCompletes() { let mainQueue = TestScheduler() - let effect = EffectTask(value: ()) + let effect = EffectTask(value: ()) .deferred(for: 1, scheduler: mainQueue) - enum Action { case start, end } + enum Action { case start, end } - let reducer = Reduce({ _, action in - switch action { - case .start: - return effect.map { .end } - case .end: - return .none - } - }) - let store = Store(initialState: (), reducer: reducer) + let reducer = Reduce({ _, action in + switch action { + case .start: + return effect.map { .end } + case .end: + return .none + } + }) + let store = Store(initialState: (), reducer: reducer) XCTAssertEqual(store.effectDisposables.count, 0) - _ = store.send(.start) + _ = store.send(.start) XCTAssertEqual(store.effectDisposables.count, 1) - mainQueue.advance(by: 2) + mainQueue.advance(by: 2) XCTAssertEqual(store.effectDisposables.count, 0) - } + } - func testScopedStoreReceivesUpdatesFromParent() { - let counterReducer = Reduce({ state, _ in - state += 1 - return .none - }) + func testScopedStoreReceivesUpdatesFromParent() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) - let parentStore = Store(initialState: 0, reducer: counterReducer) - let parentViewStore = ViewStore(parentStore) - let childStore = parentStore.scope(state: String.init) + let parentStore = Store(initialState: 0, reducer: counterReducer) + let parentViewStore = ViewStore(parentStore) + let childStore = parentStore.scope(state: String.init) - var values: [String] = [] + var values: [String] = [] let childViewStore = ViewStore(childStore) childViewStore.produced.producer .startWithValues { values.append($0) } - XCTAssertEqual(values, ["0"]) + XCTAssertEqual(values, ["0"]) - parentViewStore.send(()) + parentViewStore.send(()) - XCTAssertEqual(values, ["0", "1"]) - } + XCTAssertEqual(values, ["0", "1"]) + } - func testParentStoreReceivesUpdatesFromChild() { - let counterReducer = Reduce({ state, _ in - state += 1 - return .none - }) + func testParentStoreReceivesUpdatesFromChild() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) - let parentStore = Store(initialState: 0, reducer: counterReducer) - let childStore = parentStore.scope(state: String.init) - let childViewStore = ViewStore(childStore) + let parentStore = Store(initialState: 0, reducer: counterReducer) + let childStore = parentStore.scope(state: String.init) + let childViewStore = ViewStore(childStore) - var values: [Int] = [] + var values: [Int] = [] let parentViewStore = ViewStore(parentStore) parentViewStore.produced.producer .startWithValues { values.append($0) } - XCTAssertEqual(values, [0]) + XCTAssertEqual(values, [0]) - childViewStore.send(()) + childViewStore.send(()) - XCTAssertEqual(values, [0, 1]) - } + XCTAssertEqual(values, [0, 1]) + } - func testScopeCallCount() { - let counterReducer = Reduce({ state, _ in - state += 1 - return .none + func testScopeCallCount() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) + + var numCalls1 = 0 + _ = Store(initialState: 0, reducer: counterReducer) + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count }) - var numCalls1 = 0 - _ = Store(initialState: 0, reducer: counterReducer) - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) - - XCTAssertEqual(numCalls1, 1) - } + XCTAssertEqual(numCalls1, 1) + } - func testScopeCallCount2() { - let counterReducer = Reduce({ state, _ in - state += 1 - return .none + func testScopeCallCount2() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) + + var numCalls1 = 0 + var numCalls2 = 0 + var numCalls3 = 0 + + let store1 = Store(initialState: 0, reducer: counterReducer) + let store2 = + store1 + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) + let store3 = + store2 + .scope(state: { (count: Int) -> Int in + numCalls2 += 1 + return count + }) + let store4 = + store3 + .scope(state: { (count: Int) -> Int in + numCalls3 += 1 + return count }) - var numCalls1 = 0 - var numCalls2 = 0 - var numCalls3 = 0 - - let store1 = Store(initialState: 0, reducer: counterReducer) - let store2 = - store1 - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) - let store3 = - store2 - .scope(state: { (count: Int) -> Int in - numCalls2 += 1 - return count - }) - let store4 = - store3 - .scope(state: { (count: Int) -> Int in - numCalls3 += 1 - return count - }) - - let viewStore1 = ViewStore(store1) - let viewStore2 = ViewStore(store2) - let viewStore3 = ViewStore(store3) - let viewStore4 = ViewStore(store4) - - XCTAssertEqual(numCalls1, 1) - XCTAssertEqual(numCalls2, 1) - XCTAssertEqual(numCalls3, 1) - - viewStore4.send(()) - - XCTAssertEqual(numCalls1, 2) - XCTAssertEqual(numCalls2, 2) - XCTAssertEqual(numCalls3, 2) - - viewStore4.send(()) - - XCTAssertEqual(numCalls1, 3) - XCTAssertEqual(numCalls2, 3) - XCTAssertEqual(numCalls3, 3) - - viewStore4.send(()) - - XCTAssertEqual(numCalls1, 4) - XCTAssertEqual(numCalls2, 4) - XCTAssertEqual(numCalls3, 4) - - viewStore4.send(()) - - XCTAssertEqual(numCalls1, 5) - XCTAssertEqual(numCalls2, 5) - XCTAssertEqual(numCalls3, 5) - - _ = viewStore1 - _ = viewStore2 - _ = viewStore3 - } + let viewStore1 = ViewStore(store1) + let viewStore2 = ViewStore(store2) + let viewStore3 = ViewStore(store3) + let viewStore4 = ViewStore(store4) - func testSynchronousEffectsSentAfterSinking() { - enum Action { - case tap - case next1 - case next2 - case end - } - var values: [Int] = [] - let counterReducer = Reduce({ state, action in - switch action { - case .tap: - return .merge( - EffectTask(value: .next1), - EffectTask(value: .next2), - Effect.fireAndForget { values.append(1) } - ) - case .next1: - return .merge( - EffectTask(value: .end), - Effect.fireAndForget { values.append(2) } - ) - case .next2: - return .fireAndForget { values.append(3) } - case .end: - return .fireAndForget { values.append(4) } - } - }) + XCTAssertEqual(numCalls1, 1) + XCTAssertEqual(numCalls2, 1) + XCTAssertEqual(numCalls3, 1) - let store = Store(initialState: (), reducer: counterReducer) + viewStore4.send(()) - _ = ViewStore(store, observe: {}, removeDuplicates: ==).send(.tap) + XCTAssertEqual(numCalls1, 2) + XCTAssertEqual(numCalls2, 2) + XCTAssertEqual(numCalls3, 2) - XCTAssertEqual(values, [1, 2, 3, 4]) - } + viewStore4.send(()) - func testLotsOfSynchronousActions() { - enum Action { case incr, noop } - let reducer = Reduce({ state, action in - switch action { - case .incr: - state += 1 - return state >= 100_000 ? EffectTask(value: .noop) : EffectTask(value: .incr) - case .noop: - return .none - } - }) + XCTAssertEqual(numCalls1, 3) + XCTAssertEqual(numCalls2, 3) + XCTAssertEqual(numCalls3, 3) - let store = Store(initialState: 0, reducer: reducer) - _ = ViewStore(store, observe: { $0 }).send(.incr) - XCTAssertEqual(ViewStore(store, observe: { $0 }).state, 100_000) - } + viewStore4.send(()) - func testIfLetAfterScope() { - struct AppState: Equatable { - var count: Int? + XCTAssertEqual(numCalls1, 4) + XCTAssertEqual(numCalls2, 4) + XCTAssertEqual(numCalls3, 4) + + viewStore4.send(()) + + XCTAssertEqual(numCalls1, 5) + XCTAssertEqual(numCalls2, 5) + XCTAssertEqual(numCalls3, 5) + + _ = viewStore1 + _ = viewStore2 + _ = viewStore3 + } + + func testSynchronousEffectsSentAfterSinking() { + enum Action { + case tap + case next1 + case next2 + case end + } + var values: [Int] = [] + let counterReducer = Reduce({ state, action in + switch action { + case .tap: + return .merge( + EffectTask(value: .next1), + EffectTask(value: .next2), + Effect.fireAndForget { values.append(1) } + ) + case .next1: + return .merge( + EffectTask(value: .end), + Effect.fireAndForget { values.append(2) } + ) + case .next2: + return .fireAndForget { values.append(3) } + case .end: + return .fireAndForget { values.append(4) } } + }) - let appReducer = Reduce({ state, action in - state.count = action + let store = Store(initialState: (), reducer: counterReducer) + + _ = ViewStore(store, observe: {}, removeDuplicates: ==).send(.tap) + + XCTAssertEqual(values, [1, 2, 3, 4]) + } + + func testLotsOfSynchronousActions() { + enum Action { case incr, noop } + let reducer = Reduce({ state, action in + switch action { + case .incr: + state += 1 + return state >= 100_000 ? EffectTask(value: .noop) : EffectTask(value: .incr) + case .noop: return .none - }) + } + }) - let parentStore = Store(initialState: AppState(), reducer: appReducer) - let parentViewStore = ViewStore(parentStore) + let store = Store(initialState: 0, reducer: reducer) + _ = ViewStore(store, observe: { $0 }).send(.incr) + XCTAssertEqual(ViewStore(store, observe: { $0 }).state, 100_000) + } + + func testIfLetAfterScope() { + struct AppState: Equatable { + var count: Int? + } + + let appReducer = Reduce({ state, action in + state.count = action + return .none + }) - // NB: This test needs to hold a strong reference to the emitted stores - var outputs: [Int?] = [] - var stores: [Any] = [] + let parentStore = Store(initialState: AppState(), reducer: appReducer) + let parentViewStore = ViewStore(parentStore) - parentStore + // NB: This test needs to hold a strong reference to the emitted stores + var outputs: [Int?] = [] + var stores: [Any] = [] + + parentStore .scope(state: \.count) - .ifLet( - then: { store in - stores.append(store) - outputs.append(ViewStore(store, observe: { $0 }).state) - }, - else: { - outputs.append(nil) + .ifLet( + then: { store in + stores.append(store) + outputs.append(ViewStore(store, observe: { $0 }).state) + }, + else: { + outputs.append(nil) }) - XCTAssertEqual(outputs, [nil]) + XCTAssertEqual(outputs, [nil]) - _ = parentViewStore.send(1) - XCTAssertEqual(outputs, [nil, 1]) + _ = parentViewStore.send(1) + XCTAssertEqual(outputs, [nil, 1]) - _ = parentViewStore.send(nil) - XCTAssertEqual(outputs, [nil, 1, nil]) + _ = parentViewStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil]) - _ = parentViewStore.send(1) - XCTAssertEqual(outputs, [nil, 1, nil, 1]) + _ = parentViewStore.send(1) + XCTAssertEqual(outputs, [nil, 1, nil, 1]) - _ = parentViewStore.send(nil) - XCTAssertEqual(outputs, [nil, 1, nil, 1, nil]) + _ = parentViewStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil]) - _ = parentViewStore.send(1) - XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1]) + _ = parentViewStore.send(1) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1]) - _ = parentViewStore.send(nil) - XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) - } + _ = parentViewStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) + } - func testIfLetTwo() { - let parentStore = Store( - initialState: 0, - reducer: Reduce({ state, action in - if action { - state? += 1 - return .none - } else { - return .task { true } - } - }) - ) + func testIfLetTwo() { + let parentStore = Store( + initialState: 0, + reducer: Reduce({ state, action in + if action { + state? += 1 + return .none + } else { + return .task { true } + } + }) + ) - parentStore - .ifLet(then: { childStore in - let vs = ViewStore(childStore) + parentStore + .ifLet(then: { childStore in + let vs = ViewStore(childStore) - vs + vs .produced.producer .startWithValues { _ in } - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - XCTAssertEqual(vs.state, 3) - }) - } + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + XCTAssertEqual(vs.state, 3) + }) + } - func testActionQueuing() async { + func testActionQueuing() async { let subject = Signal.pipe() - enum Action: Equatable { - case incrementTapped - case `init` - case doIncrement - } + enum Action: Equatable { + case incrementTapped + case `init` + case doIncrement + } - let store = TestStore( - initialState: 0, - reducer: Reduce({ state, action in - switch action { - case .incrementTapped: + let store = TestStore( + initialState: 0, + reducer: Reduce({ state, action in + switch action { + case .incrementTapped: subject.input.send(value: ()) - return .none + return .none - case .`init`: + case .`init`: return subject.output.producer .map { .doIncrement } .eraseToEffect() - case .doIncrement: - state += 1 - return .none - } - }) - ) + case .doIncrement: + state += 1 + return .none + } + }) + ) - await store.send(.`init`) - await store.send(.incrementTapped) - await store.receive(.doIncrement) { - $0 = 1 - } - await store.send(.incrementTapped) - await store.receive(.doIncrement) { - $0 = 2 - } - subject.input.sendCompleted() + await store.send(.`init`) + await store.send(.incrementTapped) + await store.receive(.doIncrement) { + $0 = 1 + } + await store.send(.incrementTapped) + await store.receive(.doIncrement) { + $0 = 2 } + subject.input.sendCompleted() + } - func testCoalesceSynchronousActions() { - let store = Store( - initialState: 0, - reducer: Reduce({ state, action in - switch action { - case 0: - return .merge( - EffectTask(value: 1), - EffectTask(value: 2), - EffectTask(value: 3) - ) - default: - state = action - return .none - } - }) - ) + func testCoalesceSynchronousActions() { + let store = Store( + initialState: 0, + reducer: Reduce({ state, action in + switch action { + case 0: + return .merge( + EffectTask(value: 1), + EffectTask(value: 2), + EffectTask(value: 3) + ) + default: + state = action + return .none + } + }) + ) - var emissions: [Int] = [] - let viewStore = ViewStore(store, observe: { $0 }) + var emissions: [Int] = [] + let viewStore = ViewStore(store, observe: { $0 }) viewStore.produced.producer .startWithValues { emissions.append($0) } - XCTAssertEqual(emissions, [0]) + XCTAssertEqual(emissions, [0]) - viewStore.send(0) + viewStore.send(0) - XCTAssertEqual(emissions, [0, 3]) - } + XCTAssertEqual(emissions, [0, 3]) + } - func testBufferedActionProcessing() { - struct ChildState: Equatable { - var count: Int? - } + func testBufferedActionProcessing() { + struct ChildState: Equatable { + var count: Int? + } - struct ParentState: Equatable { - var count: Int? - var child: ChildState? - } + struct ParentState: Equatable { + var count: Int? + var child: ChildState? + } - enum ParentAction: Equatable { - case button - case child(Int?) - } + enum ParentAction: Equatable { + case button + case child(Int?) + } - var handledActions: [ParentAction] = [] - let parentReducer = Reduce({ state, action in - handledActions.append(action) + var handledActions: [ParentAction] = [] + let parentReducer = Reduce({ state, action in + handledActions.append(action) - switch action { - case .button: - state.child = .init(count: nil) - return .none + switch action { + case .button: + state.child = .init(count: nil) + return .none - case .child(let childCount): - state.count = childCount - return .none - } - }) - .ifLet(\.child, action: /ParentAction.child) { - Reduce({ state, action in - state.count = action - return .none - }) + case .child(let childCount): + state.count = childCount + return .none } + }) + .ifLet(\.child, action: /ParentAction.child) { + Reduce({ state, action in + state.count = action + return .none + }) + } - let parentStore = Store( - initialState: ParentState(), - reducer: parentReducer - ) + let parentStore = Store( + initialState: ParentState(), + reducer: parentReducer + ) - parentStore - .scope( - state: \.child, - action: ParentAction.child - ) - .ifLet { childStore in - ViewStore(childStore).send(2) - } + parentStore + .scope( + state: \.child, + action: ParentAction.child + ) + .ifLet { childStore in + ViewStore(childStore).send(2) + } - XCTAssertEqual(handledActions, []) + XCTAssertEqual(handledActions, []) - _ = ViewStore(parentStore).send(.button) - XCTAssertEqual( - handledActions, - [ - .button, - .child(2), - ]) - } + _ = ViewStore(parentStore).send(.button) + XCTAssertEqual( + handledActions, + [ + .button, + .child(2), + ]) + } - func testCascadingTaskCancellation() async { - enum Action { case task, response, response1, response2 } - let reducer = Reduce({ state, action in - switch action { - case .task: - return .task { .response } - case .response: - return .merge( + func testCascadingTaskCancellation() async { + enum Action { case task, response, response1, response2 } + let reducer = Reduce({ state, action in + switch action { + case .task: + return .task { .response } + case .response: + return .merge( SignalProducer { _, _ in }.eraseToEffect(), - .task { .response1 } - ) - case .response1: - return .merge( + .task { .response1 } + ) + case .response1: + return .merge( SignalProducer { _, _ in }.eraseToEffect(), - .task { .response2 } - ) - case .response2: + .task { .response2 } + ) + case .response2: return SignalProducer { _, _ in }.eraseToEffect() - } - }) - - let store = TestStore( - initialState: 0, - reducer: reducer - ) + } + }) + + let store = TestStore( + initialState: 0, + reducer: reducer + ) + + let task = await store.send(.task) + await store.receive(.response) + await store.receive(.response1) + await store.receive(.response2) + await task.cancel() + } - let task = await store.send(.task) - await store.receive(.response) - await store.receive(.response1) - await store.receive(.response2) - await task.cancel() - } + func testTaskCancellationEmpty() async { + enum Action { case task } - func testTaskCancellationEmpty() async { - enum Action { case task } - - let store = TestStore( - initialState: 0, - reducer: Reduce({ state, action in - switch action { - case .task: - return .fireAndForget { try await Task.never() } - } - }) - ) + let store = TestStore( + initialState: 0, + reducer: Reduce({ state, action in + switch action { + case .task: + return .fireAndForget { try await Task.never() } + } + }) + ) - await store.send(.task).cancel() - } + await store.send(.task).cancel() + } - func testScopeCancellation() async throws { - let neverEndingTask = Task { try await Task.never() } + func testScopeCancellation() async throws { + let neverEndingTask = Task { try await Task.never() } - let store = Store( - initialState: (), - reducer: Reduce({ _, _ in - .fireAndForget { - try await neverEndingTask.value - } - }) - ) - let scopedStore = store.scope(state: { $0 }) + let store = Store( + initialState: (), + reducer: Reduce({ _, _ in + .fireAndForget { + try await neverEndingTask.value + } + }) + ) + let scopedStore = store.scope(state: { $0 }) - let sendTask = scopedStore.send(()) - await Task.yield() - neverEndingTask.cancel() - try await XCTUnwrap(sendTask).value + let sendTask = scopedStore.send(()) + await Task.yield() + neverEndingTask.cancel() + try await XCTUnwrap(sendTask).value XCTAssertEqual(store.effectDisposables.count, 0) XCTAssertEqual(scopedStore.effectDisposables.count, 0) - } + } - func testOverrideDependenciesDirectlyOnReducer() { - 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 - } + func testOverrideDependenciesDirectlyOnReducer() { + 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 = Store( - initialState: 0, - reducer: Counter() - .dependency(\.calendar, Calendar(identifier: .gregorian)) - .dependency(\.locale, Locale(identifier: "en_US")) - .dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!) - .dependency(\.urlSession, URLSession(configuration: .ephemeral)) - ) - - ViewStore(store, observe: { $0 }).send(true) } - func testOverrideDependenciesDirectlyOnStore() { - struct MyReducer: ReducerProtocol { - @Dependency(\.uuid) var uuid + let store = Store( + initialState: 0, + reducer: Counter() + .dependency(\.calendar, Calendar(identifier: .gregorian)) + .dependency(\.locale, Locale(identifier: "en_US")) + .dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!) + .dependency(\.urlSession, URLSession(configuration: .ephemeral)) + ) - func reduce(into state: inout UUID, action: Void) -> EffectTask { - state = self.uuid() - return .none - } - } + ViewStore(store, observe: { $0 }).send(true) + } + func testOverrideDependenciesDirectlyOnStore() { + struct MyReducer: ReducerProtocol { @Dependency(\.uuid) var uuid - let store = Store(initialState: uuid(), reducer: MyReducer()) { - $0.uuid = .constant(UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) + func reduce(into state: inout UUID, action: Void) -> EffectTask { + state = self.uuid() + return .none } - let viewStore = ViewStore(store, observe: { $0 }) + } + + @Dependency(\.uuid) var uuid - XCTAssertEqual(viewStore.state, UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) + 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")!) + } + + 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) } +} #endif From 7f62abd88e4a31aa2b7296aa193a94de332dd345 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 30 Jan 2023 10:57:04 -0800 Subject: [PATCH 5/8] Workaround for BindingAction existential layout crash (#1881) * Add test case for binding action crash * Workaround layout issue * flakey test * wip (cherry picked from commit c3304961646bd6b6fd71e311d48950b1d63bd930) # Conflicts: # Tests/ComposableArchitectureTests/BindingTests.swift --- .../SwiftUI/Binding.swift | 25 +++++- .../BindingTests.swift | 79 +++++++++++-------- .../TestStoreNonExhaustiveTests.swift | 54 +++++++------ 3 files changed, 98 insertions(+), 60 deletions(-) diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index 78b1e63b3..d51860245 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -171,9 +171,32 @@ public struct BindingAction: Equatable { @usableFromInline let set: (inout Root) -> Void - let value: Any + // NB: swift(<5.8) has an enum existential layout bug that can cause crashes when extracting + // payloads. We can box the existential to work around the bug. + #if swift(<5.8) + private let _value: [Any] + var value: Any { self._value[0] } + #else + let value: Any + #endif let valueIsEqualTo: (Any) -> Bool + init( + keyPath: PartialKeyPath, + set: @escaping (inout Root) -> Void, + value: Any, + valueIsEqualTo: @escaping (Any) -> Bool + ) { + self.keyPath = keyPath + self.set = set + #if swift(<5.8) + self._value = [value] + #else + self.value = value + #endif + self.valueIsEqualTo = valueIsEqualTo + } + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.keyPath == rhs.keyPath && lhs.valueIsEqualTo(rhs.value) } diff --git a/Tests/ComposableArchitectureTests/BindingTests.swift b/Tests/ComposableArchitectureTests/BindingTests.swift index f1a290603..4d8cc90db 100644 --- a/Tests/ComposableArchitectureTests/BindingTests.swift +++ b/Tests/ComposableArchitectureTests/BindingTests.swift @@ -1,46 +1,59 @@ #if canImport(SwiftUI) - import ComposableArchitecture - import XCTest - - @MainActor - final class BindingTests: XCTestCase { - #if swift(>=5.7) - func testNestedBindingState() { - struct BindingTest: ReducerProtocol { - struct State: Equatable { - @BindingState var nested = Nested() - - struct Nested: Equatable { - var field = "" - } +import ComposableArchitecture +import XCTest + +@MainActor +final class BindingTests: XCTestCase { + #if swift(>=5.7) + func testNestedBindingState() { + struct BindingTest: ReducerProtocol { + struct State: Equatable { + @BindingState var nested = Nested() + + struct Nested: Equatable { + var field = "" } + } - enum Action: BindableAction, Equatable { - case binding(BindingAction) - } + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } - var body: some ReducerProtocol { - BindingReducer() - Reduce { state, action in - switch action { - case .binding(\.$nested.field): - state.nested.field += "!" - return .none - default: - return .none - } + var body: some ReducerProtocol { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.$nested.field): + state.nested.field += "!" + return .none + default: + return .none } } } + } - let store = Store(initialState: BindingTest.State(), reducer: BindingTest()) + let store = Store(initialState: BindingTest.State(), reducer: BindingTest()) - let viewStore = ViewStore(store, observe: { $0 }) + let viewStore = ViewStore(store, observe: { $0 }) - viewStore.binding(\.$nested.field).wrappedValue = "Hello" + viewStore.binding(\.$nested.field).wrappedValue = "Hello" - XCTAssertEqual(viewStore.state, .init(nested: .init(field: "Hello!"))) - } - #endif + XCTAssertEqual(viewStore.state, .init(nested: .init(field: "Hello!"))) + } + #endif + + // NB: This crashes in Swift(<5.8) RELEASE when `BindingAction` holds directly onto an unboxed + // `value: Any` existential + func testLayoutBug() { + enum Foo { + case bar(Baz) +} + enum Baz { + case fizz(BindingAction) + case buzz(Bool) + } + _ = (/Foo.bar).extract(from: .bar(.buzz(true))) } +} #endif diff --git a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift index 97d8059bc..c01c7763a 100644 --- a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift @@ -460,38 +460,40 @@ // Confirms that when you send an action the test store skips any unreceived actions // automatically. func testSendWithUnreceivedActions_SkipsActions() async { - struct Feature: ReducerProtocol { - enum Action: Equatable { - case tap - case response(Int) - } - func reduce(into state: inout Int, action: Action) -> EffectTask { - switch action { - case .tap: - state += 1 - return .task { [state] in .response(state + 42) } - case let .response(number): - state = number - return .none + await _withMainSerialExecutor { + struct Feature: ReducerProtocol { + enum Action: Equatable { + case tap + case response(Int) + } + func reduce(into state: inout Int, action: Action) -> EffectTask { + switch action { + case .tap: + state += 1 + return .task { [state] in .response(state + 42) } + case let .response(number): + state = number + return .none + } } } - } - let store = TestStore( - initialState: 0, - reducer: Feature() - ) - store.exhaustivity = .off + let store = TestStore( + initialState: 0, + reducer: Feature() + ) + store.exhaustivity = .off - await store.send(.tap) - XCTAssertEqual(store.state, 1) + await store.send(.tap) + XCTAssertEqual(store.state, 1) - // Ignored received action: .response(43) - await store.send(.tap) - XCTAssertEqual(store.state, 44) + // Ignored received action: .response(43) + await store.send(.tap) + XCTAssertEqual(store.state, 44) - await store.skipReceivedActions() - XCTAssertEqual(store.state, 86) + await store.skipReceivedActions() + XCTAssertEqual(store.state, 86) + } } func testPartialExhaustivityPrefix() async { From fb1771417085c59b71707c3617421a8d2eebf281 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 30 Jan 2023 12:12:14 -0800 Subject: [PATCH 6/8] Update to latest Contributor Covenant (cherry picked from commit 00738db7b9176588dff08e78de6daa18f7cb3728) --- .github/CODE_OF_CONDUCT.md | 106 +++++++++++++++++++++++++++---------- 1 file changed, 77 insertions(+), 29 deletions(-) diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 703a4725a..6f9886f04 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -2,83 +2,131 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. -We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to a positive environment for our community include: +Examples of behavior that contributes to a positive environment for our +community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall community +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or - advances of any kind +* The use of sexualized language or imagery, and sexual attention or advances of + any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission +* Publishing others' private information, such as a physical or email address, + without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities -Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at support@pointfree.co. All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. -All community leaders are obligated to respect the privacy and security of the reporter of any incident. +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. ## Enforcement Guidelines -Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction -**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. -**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. ### 2. Warning -**Community Impact**: A violation through a single incident or series of actions. +**Community Impact**: A violation through a single incident or series of +actions. -**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. ### 3. Temporary Ban -**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. -**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. ### 4. Permanent Ban -**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. -**Consequence**: A permanent ban from any sort of public interaction within the community. +**Consequence**: A permanent ban from any sort of public interaction within the +community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, -available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. -Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations From a1eddf547e09c5707e8aacf2db2b4320d40b5fd8 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 30 Jan 2023 12:31:23 -0800 Subject: [PATCH 7/8] Make `Store.filter` internal (#1882) * Make Store.filter internal We're seeing that `@_spi` can still come up in autocomplete. Now Xcode surfaces internal stuff too occasionally, but maybe this will suppress it a bit. * wip * fix (cherry picked from commit b690a617d1366bd36f047e5da5d3185f20daac71) --- Sources/ComposableArchitecture/Store.swift | 2 +- .../StoreFilterTests.swift | 28 +++++++++++++++++++ .../StoreTests.swift | 17 ----------- 3 files changed, 29 insertions(+), 18 deletions(-) create mode 100644 Tests/ComposableArchitectureTests/StoreFilterTests.swift diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index aa5ed2c8d..94289b0f7 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -334,7 +334,7 @@ public final class Store { self.scope(state: toChildState, action: { $0 }) } - @_spi(Internals) public func filter( + func filter( _ isSent: @escaping (State, Action) -> Bool ) -> Store { self.threadCheck(status: .scope) diff --git a/Tests/ComposableArchitectureTests/StoreFilterTests.swift b/Tests/ComposableArchitectureTests/StoreFilterTests.swift new file mode 100644 index 000000000..63ea7a3ad --- /dev/null +++ b/Tests/ComposableArchitectureTests/StoreFilterTests.swift @@ -0,0 +1,28 @@ +#if DEBUG + import Combine + import XCTest + + @testable import ComposableArchitecture + + @MainActor + final class StoreFilterTests: XCTestCase { + var cancellables: Set = [] + + 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) + } + } +#endif diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index 9940ea32a..38c09546b 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -588,22 +588,5 @@ 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) - } } #endif From 396ae722f00708d1e41aa6c20427fc74aaddac3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pacheco=20Neves?= Date: Tue, 31 Jan 2023 20:16:55 +0000 Subject: [PATCH 8/8] Fix builds, tests, warnings and run swift-format --- .../Effects/SignalProducer.swift | 6 +- Sources/ComposableArchitecture/Store.swift | 14 +- .../SwiftUI/IfLetStore.swift | 196 ++-- .../ComposableArchitecture/ViewStore.swift | 556 ++++++------ .../BindingLocalTests.swift | 2 +- .../BindingTests.swift | 90 +- .../EffectCancellationTests.swift | 6 +- .../RuntimeWarningTests.swift | 2 - .../StoreFilterTests.swift | 11 +- .../StoreTests.swift | 854 +++++++++--------- 10 files changed, 868 insertions(+), 869 deletions(-) diff --git a/Sources/ComposableArchitecture/Effects/SignalProducer.swift b/Sources/ComposableArchitecture/Effects/SignalProducer.swift index 9d5d663c4..7bb92939c 100644 --- a/Sources/ComposableArchitecture/Effects/SignalProducer.swift +++ b/Sources/ComposableArchitecture/Effects/SignalProducer.swift @@ -323,9 +323,9 @@ extension EffectProducer where Failure == Swift.Error { } } -extension Effect { +extension EffectProducer { - /// Turns any effect into an ``Effect`` for any output and failure type by ignoring all output + /// Turns any effect into an ``EffectProducer`` for any output and failure type by ignoring all output /// and any failure. /// /// This is useful for times you want to fire off an effect but don't want to feed any data back @@ -348,7 +348,7 @@ extension Effect { public func fireAndForget( outputType: NewValue.Type = NewValue.self, failureType: NewError.Type = NewError.self - ) -> Effect { + ) -> EffectProducer { self .producer .flatMapError { _ in .empty } diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 94289b0f7..b38d3ddc1 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -399,16 +399,16 @@ public final class Store { } }, completed: { [weak self] in - self?.threadCheck(status: .effectCompletion(action)) - boxedTask.wrappedValue?.cancel() - didComplete = true + self?.threadCheck(status: .effectCompletion(action)) + boxedTask.wrappedValue?.cancel() + didComplete = true self?.effectDisposables.removeValue(forKey: uuid)?.dispose() - }, + }, interrupted: { [weak self] in boxedTask.wrappedValue?.cancel() didComplete = true self?.effectDisposables.removeValue(forKey: uuid)?.dispose() - } + } ) let effectDisposable = CompositeDisposable() @@ -416,7 +416,7 @@ public final class Store { effectDisposable += AnyDisposable { [weak self] in self?.threadCheck(status: .effectCompletion(action)) self?.effectDisposables.removeValue(forKey: uuid)?.dispose() - } + } if !didComplete { let task = Task { @MainActor in @@ -677,7 +677,7 @@ public typealias StoreOf = Store let reducer = ScopedReducer( rootStore: self.rootStore, state: { _ in toRescopedState(store.state) }, - action: { fromRescopedAction($0, $1).flatMap { fromScopedAction(store.state.value, $0) } }, + action: { fromRescopedAction($0, $1).flatMap { fromScopedAction(store.state, $0) } }, parentStores: self.parentStores + [store] ) let childStore = Store( diff --git a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift index 467e029b8..d7a085e55 100644 --- a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift @@ -1,112 +1,112 @@ #if canImport(SwiftUI) -import SwiftUI + import SwiftUI -/// A view that safely unwraps a store of optional state in order to show one of two views. -/// -/// When the underlying state is non-`nil`, the `then` closure will be performed with a ``Store`` -/// that holds onto non-optional state, and otherwise the `else` closure will be performed. -/// -/// This is useful for deciding between two views to show depending on an optional piece of state: -/// -/// ```swift -/// IfLetStore( -/// store.scope(state: \SearchState.results, action: SearchAction.results), -/// ) { -/// SearchResultsView(store: $0) -/// } else: { -/// Text("Loading search results...") -/// } -/// ``` -/// -/// And for showing a sheet when a piece of state becomes non-`nil`: -/// -/// ```swift -/// .sheet( -/// isPresented: viewStore.binding( -/// get: \.isGameActive, -/// send: { $0 ? .startButtonTapped : .detailDismissed } -/// ) -/// ) { -/// IfLetStore( -/// self.store.scope(state: \.detail, action: AppAction.detail) -/// ) { -/// DetailView(store: $0) -/// } -/// } -/// ``` -/// + /// A view that safely unwraps a store of optional state in order to show one of two views. + /// + /// When the underlying state is non-`nil`, the `then` closure will be performed with a ``Store`` + /// that holds onto non-optional state, and otherwise the `else` closure will be performed. + /// + /// This is useful for deciding between two views to show depending on an optional piece of state: + /// + /// ```swift + /// IfLetStore( + /// store.scope(state: \SearchState.results, action: SearchAction.results), + /// ) { + /// SearchResultsView(store: $0) + /// } else: { + /// Text("Loading search results...") + /// } + /// ``` + /// + /// And for showing a sheet when a piece of state becomes non-`nil`: + /// + /// ```swift + /// .sheet( + /// isPresented: viewStore.binding( + /// get: \.isGameActive, + /// send: { $0 ? .startButtonTapped : .detailDismissed } + /// ) + /// ) { + /// IfLetStore( + /// self.store.scope(state: \.detail, action: AppAction.detail) + /// ) { + /// DetailView(store: $0) + /// } + /// } + /// ``` + /// public struct IfLetStore: View where Content: View { - private let content: (ViewStore) -> Content - private let store: Store + private let content: (ViewStore) -> Content + private let store: Store - /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional - /// state is `nil` or non-`nil`. - /// - /// - Parameters: - /// - store: A store of optional state. - /// - ifContent: A function that is given a store of non-optional state and returns a view that - /// is visible only when the optional state is non-`nil`. - /// - elseContent: A view that is only visible when the optional state is `nil`. - public init( - _ store: Store, - @ViewBuilder then ifContent: @escaping (Store) -> IfContent, - @ViewBuilder else elseContent: () -> ElseContent - ) where Content == _ConditionalContent { - self.store = store - let elseContent = elseContent() - self.content = { viewStore in - if var state = viewStore.state { - return ViewBuilder.buildEither( - first: ifContent( + /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional + /// state is `nil` or non-`nil`. + /// + /// - Parameters: + /// - store: A store of optional state. + /// - ifContent: A function that is given a store of non-optional state and returns a view that + /// is visible only when the optional state is non-`nil`. + /// - elseContent: A view that is only visible when the optional state is `nil`. + public init( + _ store: Store, + @ViewBuilder then ifContent: @escaping (Store) -> IfContent, + @ViewBuilder else elseContent: () -> ElseContent + ) where Content == _ConditionalContent { + self.store = store + let elseContent = elseContent() + self.content = { viewStore in + if var state = viewStore.state { + return ViewBuilder.buildEither( + first: ifContent( + store + .filter { state, _ in state == nil ? !BindingLocal.isActive : true } + .scope { + state = $0 ?? state + return state + } + ) + ) + } else { + return ViewBuilder.buildEither(second: elseContent) + } + } + } + + /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional + /// state is `nil` or non-`nil`. + /// + /// - Parameters: + /// - store: A store of optional state. + /// - ifContent: A function that is given a store of non-optional state and returns a view that + /// is visible only when the optional state is non-`nil`. + public init( + _ store: Store, + @ViewBuilder then ifContent: @escaping (Store) -> IfContent + ) where Content == IfContent? { + self.store = store + self.content = { viewStore in + if var state = viewStore.state { + return ifContent( store .filter { state, _ in state == nil ? !BindingLocal.isActive : true } .scope { - state = $0 ?? state - return state - } + state = $0 ?? state + return state + } ) - ) - } else { - return ViewBuilder.buildEither(second: elseContent) + } else { + return nil + } } } - } - /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional - /// state is `nil` or non-`nil`. - /// - /// - Parameters: - /// - store: A store of optional state. - /// - ifContent: A function that is given a store of non-optional state and returns a view that - /// is visible only when the optional state is non-`nil`. - public init( - _ store: Store, - @ViewBuilder then ifContent: @escaping (Store) -> IfContent - ) where Content == IfContent? { - self.store = store - self.content = { viewStore in - if var state = viewStore.state { - return ifContent( - store - .filter { state, _ in state == nil ? !BindingLocal.isActive : true } - .scope { - state = $0 ?? state - return state - } - ) - } else { - return nil - } + public var body: some View { + WithViewStore( + self.store, + observe: { $0 }, + removeDuplicates: { ($0 != nil) == ($1 != nil) }, + content: self.content + ) } } - - public var body: some View { - WithViewStore( - self.store, - observe: { $0 }, - removeDuplicates: { ($0 != nil) == ($1 != nil) }, - content: self.content - ) - } -} #endif diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index 49e65c065..9e3e06e17 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -1,10 +1,10 @@ import ReactiveSwift #if canImport(Combine) -import Combine + import Combine #endif #if canImport(SwiftUI) -import SwiftUI + import SwiftUI #endif /// A `ViewStore` is an object that can observe state changes and send actions. They are most @@ -108,7 +108,7 @@ public final class ViewStore { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in guard let objectWillChange = objectWillChange, let _state = _state else { return } #if canImport(Combine) - objectWillChange.send() + objectWillChange.send() #endif _state.value = $0 } @@ -144,7 +144,7 @@ public final class ViewStore { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in guard let objectWillChange = objectWillChange, let _state = _state else { return } #if canImport(Combine) - objectWillChange.send() + objectWillChange.send() #endif _state.value = $0 } @@ -224,7 +224,7 @@ public final class ViewStore { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in guard let objectWillChange = objectWillChange, let _state = _state else { return } #if canImport(Combine) - objectWillChange.send() + objectWillChange.send() #endif _state.value = $0 } @@ -297,303 +297,303 @@ public final class ViewStore { } #if canImport(SwiftUI) - /// Sends an action to the store with a given animation. - /// - /// See ``ViewStore/send(_:)`` for more info. - /// - /// - Parameters: - /// - action: An action. - /// - animation: An animation. - @discardableResult - public func send(_ action: ViewAction, animation: Animation?) -> ViewStoreTask { - send(action, transaction: Transaction(animation: animation)) - } + /// Sends an action to the store with a given animation. + /// + /// See ``ViewStore/send(_:)`` for more info. + /// + /// - Parameters: + /// - action: An action. + /// - animation: An animation. + @discardableResult + 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. - /// - /// - Parameters: - /// - action: An action. - /// - transaction: A transaction. - @discardableResult - public func send(_ action: ViewAction, transaction: Transaction) -> ViewStoreTask { - withTransaction(transaction) { - self.send(action) + /// 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) + } } - } #endif #if canImport(_Concurrency) && compiler(>=5.5.2) - /// Sends an action into the store and then suspends while a piece of state is `true`. - /// - /// This method can be used to interact with async/await code, allowing you to suspend while work - /// is being performed in an effect. One common example of this is using SwiftUI's `.refreshable` - /// method, which shows a loading indicator on the screen while work is being performed. - /// - /// For example, suppose we wanted to load some data from the network when a pull-to-refresh - /// gesture is performed on a list. The domain and logic for this feature can be modeled like so: - /// - /// ```swift - /// struct Feature: ReducerProtocol { - /// struct State: Equatable { - /// var isLoading = false - /// var response: String? - /// } - /// enum Action { - /// case pulledToRefresh - /// case receivedResponse(TaskResult) - /// } - /// @Dependency(\.fetch) var fetch - /// - /// func reduce(into state: inout State, action: Action) -> EffectTask { - /// switch action { - /// case .pulledToRefresh: - /// state.isLoading = true - /// return .task { - /// await .receivedResponse(TaskResult { try await self.fetch() }) - /// } - /// - /// case let .receivedResponse(result): - /// state.isLoading = false - /// state.response = try? result.value - /// return .none - /// } - /// } - /// } - /// ``` - /// - /// Note that we keep track of an `isLoading` boolean in our state so that we know exactly when - /// the network response is being performed. - /// - /// The view can show the fact in a `List`, if it's present, and we can use the `.refreshable` - /// view modifier to enhance the list with pull-to-refresh capabilities: - /// - /// ```swift - /// struct MyView: View { - /// let store: Store - /// - /// var body: some View { - /// WithViewStore(self.store, observe: { $0 }) { viewStore in - /// List { - /// if let response = viewStore.response { - /// Text(response) - /// } - /// } - /// .refreshable { - /// await viewStore.send(.pulledToRefresh, while: \.isLoading) - /// } - /// } - /// } - /// } - /// ``` - /// - /// Here we've used the ``send(_:while:)`` method to suspend while the `isLoading` state is - /// `true`. Once that piece of state flips back to `false` the method will resume, signaling to - /// `.refreshable` that the work has finished which will cause the loading indicator to disappear. - /// - /// - Parameters: - /// - action: An action. - /// - predicate: A predicate on `ViewState` that determines for how long this method should - /// suspend. - @MainActor - public func send(_ action: ViewAction, while predicate: @escaping (ViewState) -> Bool) async { - let task = self.send(action) - await withTaskCancellationHandler { - await self.yield(while: predicate) - } onCancel: { - task.rawValue?.cancel() + /// Sends an action into the store and then suspends while a piece of state is `true`. + /// + /// This method can be used to interact with async/await code, allowing you to suspend while work + /// is being performed in an effect. One common example of this is using SwiftUI's `.refreshable` + /// method, which shows a loading indicator on the screen while work is being performed. + /// + /// For example, suppose we wanted to load some data from the network when a pull-to-refresh + /// gesture is performed on a list. The domain and logic for this feature can be modeled like so: + /// + /// ```swift + /// struct Feature: ReducerProtocol { + /// struct State: Equatable { + /// var isLoading = false + /// var response: String? + /// } + /// enum Action { + /// case pulledToRefresh + /// case receivedResponse(TaskResult) + /// } + /// @Dependency(\.fetch) var fetch + /// + /// func reduce(into state: inout State, action: Action) -> EffectTask { + /// switch action { + /// case .pulledToRefresh: + /// state.isLoading = true + /// return .task { + /// await .receivedResponse(TaskResult { try await self.fetch() }) + /// } + /// + /// case let .receivedResponse(result): + /// state.isLoading = false + /// state.response = try? result.value + /// return .none + /// } + /// } + /// } + /// ``` + /// + /// Note that we keep track of an `isLoading` boolean in our state so that we know exactly when + /// the network response is being performed. + /// + /// The view can show the fact in a `List`, if it's present, and we can use the `.refreshable` + /// view modifier to enhance the list with pull-to-refresh capabilities: + /// + /// ```swift + /// struct MyView: View { + /// let store: Store + /// + /// var body: some View { + /// WithViewStore(self.store, observe: { $0 }) { viewStore in + /// List { + /// if let response = viewStore.response { + /// Text(response) + /// } + /// } + /// .refreshable { + /// await viewStore.send(.pulledToRefresh, while: \.isLoading) + /// } + /// } + /// } + /// } + /// ``` + /// + /// Here we've used the ``send(_:while:)`` method to suspend while the `isLoading` state is + /// `true`. Once that piece of state flips back to `false` the method will resume, signaling to + /// `.refreshable` that the work has finished which will cause the loading indicator to disappear. + /// + /// - Parameters: + /// - action: An action. + /// - predicate: A predicate on `ViewState` that determines for how long this method should + /// suspend. + @MainActor + public func send(_ action: ViewAction, while predicate: @escaping (ViewState) -> Bool) async { + let task = self.send(action) + await withTaskCancellationHandler { + await self.yield(while: predicate) + } onCancel: { + task.rawValue?.cancel() + } } - } #if canImport(SwiftUI) - /// Sends an action into the store and then suspends while a piece of state is `true`. - /// - /// See the documentation of ``send(_:while:)`` for more information. - /// - /// - Parameters: - /// - 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. - @MainActor - public func send( - _ action: ViewAction, - animation: Animation?, - while predicate: @escaping (ViewState) -> Bool - ) async { - let task = withAnimation(animation) { self.send(action) } - await withTaskCancellationHandler { - await self.yield(while: predicate) - } onCancel: { - task.rawValue?.cancel() - } - } + /// Sends an action into the store and then suspends while a piece of state is `true`. + /// + /// See the documentation of ``send(_:while:)`` for more information. + /// + /// - Parameters: + /// - 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. + @MainActor + public func send( + _ action: ViewAction, + animation: Animation?, + while predicate: @escaping (ViewState) -> Bool + ) async { + let task = withAnimation(animation) { self.send(action) } + await withTaskCancellationHandler { + await self.yield(while: predicate) + } onCancel: { + task.rawValue?.cancel() + } + } #endif - /// Suspends the current task while a predicate on state is `true`. - /// - /// If you want to suspend at the same time you send an action to the view store, use - /// ``send(_:while:)``. - /// - /// - Parameter predicate: A predicate on `ViewState` that determines for how long this method - /// should suspend. - @MainActor - public func yield(while predicate: @escaping (ViewState) -> Bool) async { - if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + /// Suspends the current task while a predicate on state is `true`. + /// + /// If you want to suspend at the same time you send an action to the view store, use + /// ``send(_:while:)``. + /// + /// - Parameter predicate: A predicate on `ViewState` that determines for how long this method + /// should suspend. + @MainActor + public func yield(while predicate: @escaping (ViewState) -> Bool) async { + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { _ = await self.produced.producer - .values - .first(where: { !predicate($0) }) - } else { + .values + .first(where: { !predicate($0) }) + } else { let cancellable = Box(wrappedValue: nil) - try? await withTaskCancellationHandler { - try Task.checkCancellation() - try await withUnsafeThrowingContinuation { - (continuation: UnsafeContinuation) in - guard !Task.isCancelled else { - continuation.resume(throwing: CancellationError()) - return - } + try? await withTaskCancellationHandler { + try Task.checkCancellation() + try await withUnsafeThrowingContinuation { + (continuation: UnsafeContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } cancellable.wrappedValue = self.produced.producer - .filter { !predicate($0) } + .filter { !predicate($0) } .take(first: 1) .startWithValues { _ in - continuation.resume() - _ = cancellable - } - } - } onCancel: { + continuation.resume() + _ = cancellable + } + } + } onCancel: { cancellable.wrappedValue?.dispose() + } } } - } #endif #if canImport(SwiftUI) - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, a text field binding can be created like this: - /// - /// ```swift - /// struct State { var name = "" } - /// enum Action { case nameChanged(String) } - /// - /// TextField( - /// "Enter name", - /// text: viewStore.binding( - /// get: { $0.name }, - /// send: { Action.nameChanged($0) } - /// ) - /// ) - /// ``` - /// - /// - Parameters: - /// - get: A function to get the state for the binding from the view store's full state. - /// - valueToAction: A function that transforms the binding's value into an action that can be - /// sent to the store. - /// - Returns: A binding. - public func binding( - get: @escaping (ViewState) -> Value, - send valueToAction: @escaping (Value) -> ViewAction - ) -> Binding { - ObservedObject(wrappedValue: self) - .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] - } - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, an alert binding can be dealt with like this: - /// - /// ```swift - /// struct State { var alert: String? } - /// enum Action { case alertDismissed } - /// - /// .alert( - /// item: self.store.binding( - /// get: { $0.alert }, - /// send: .alertDismissed - /// ) - /// ) { alert in Alert(title: Text(alert.message)) } - /// ``` - /// - /// - Parameters: - /// - get: A function to get the state for the binding from the view store's full state. - /// - action: The action to send when the binding is written to. - /// - Returns: A binding. - public func binding( - get: @escaping (ViewState) -> Value, - send action: ViewAction - ) -> Binding { - self.binding(get: get, send: { _ in action }) - } + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, a text field binding can be created like this: + /// + /// ```swift + /// struct State { var name = "" } + /// enum Action { case nameChanged(String) } + /// + /// TextField( + /// "Enter name", + /// text: viewStore.binding( + /// get: { $0.name }, + /// send: { Action.nameChanged($0) } + /// ) + /// ) + /// ``` + /// + /// - Parameters: + /// - get: A function to get the state for the binding from the view store's full state. + /// - valueToAction: A function that transforms the binding's value into an action that can be + /// sent to the store. + /// - Returns: A binding. + public func binding( + get: @escaping (ViewState) -> Value, + send valueToAction: @escaping (Value) -> ViewAction + ) -> Binding { + ObservedObject(wrappedValue: self) + .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] + } + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, an alert binding can be dealt with like this: + /// + /// ```swift + /// struct State { var alert: String? } + /// enum Action { case alertDismissed } + /// + /// .alert( + /// item: self.store.binding( + /// get: { $0.alert }, + /// send: .alertDismissed + /// ) + /// ) { alert in Alert(title: Text(alert.message)) } + /// ``` + /// + /// - Parameters: + /// - get: A function to get the state for the binding from the view store's full state. + /// - action: The action to send when the binding is written to. + /// - Returns: A binding. + public func binding( + get: @escaping (ViewState) -> Value, + send action: ViewAction + ) -> Binding { + self.binding(get: get, send: { _ in action }) + } - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, a text field binding can be created like this: - /// - /// ```swift - /// typealias State = String - /// enum Action { case nameChanged(String) } - /// - /// TextField( - /// "Enter name", - /// text: viewStore.binding( - /// send: { Action.nameChanged($0) } - /// ) - /// ) - /// ``` - /// - /// - Parameters: - /// - valueToAction: A function that transforms the binding's value into an action that can be - /// sent to the store. - /// - Returns: A binding. - public func binding( - send valueToAction: @escaping (ViewState) -> ViewAction - ) -> Binding { - self.binding(get: { $0 }, send: valueToAction) - } + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, a text field binding can be created like this: + /// + /// ```swift + /// typealias State = String + /// enum Action { case nameChanged(String) } + /// + /// TextField( + /// "Enter name", + /// text: viewStore.binding( + /// send: { Action.nameChanged($0) } + /// ) + /// ) + /// ``` + /// + /// - Parameters: + /// - valueToAction: A function that transforms the binding's value into an action that can be + /// sent to the store. + /// - Returns: A binding. + public func binding( + send valueToAction: @escaping (ViewState) -> ViewAction + ) -> Binding { + self.binding(get: { $0 }, send: valueToAction) + } - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, an alert binding can be dealt with like this: - /// - /// ```swift - /// typealias State = String - /// enum Action { case alertDismissed } - /// - /// .alert( - /// item: viewStore.binding( - /// send: .alertDismissed - /// ) - /// ) { title in Alert(title: Text(title)) } - /// ``` - /// - /// - Parameters: - /// - action: The action to send when the binding is written to. - /// - Returns: A binding. - public func binding(send action: ViewAction) -> Binding { - self.binding(send: { _ in action }) - } + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, an alert binding can be dealt with like this: + /// + /// ```swift + /// typealias State = String + /// enum Action { case alertDismissed } + /// + /// .alert( + /// item: viewStore.binding( + /// send: .alertDismissed + /// ) + /// ) { title in Alert(title: Text(title)) } + /// ``` + /// + /// - Parameters: + /// - action: The action to send when the binding is written to. + /// - Returns: A binding. + public func binding(send action: ViewAction) -> Binding { + self.binding(send: { _ in action }) + } #endif private subscript( diff --git a/Tests/ComposableArchitectureTests/BindingLocalTests.swift b/Tests/ComposableArchitectureTests/BindingLocalTests.swift index 62a4326e5..059de1c84 100644 --- a/Tests/ComposableArchitectureTests/BindingLocalTests.swift +++ b/Tests/ComposableArchitectureTests/BindingLocalTests.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG && !os(Linux) import XCTest @testable import ComposableArchitecture diff --git a/Tests/ComposableArchitectureTests/BindingTests.swift b/Tests/ComposableArchitectureTests/BindingTests.swift index 4d8cc90db..7af4c6f4f 100644 --- a/Tests/ComposableArchitectureTests/BindingTests.swift +++ b/Tests/ComposableArchitectureTests/BindingTests.swift @@ -1,59 +1,59 @@ #if canImport(SwiftUI) -import ComposableArchitecture -import XCTest - -@MainActor -final class BindingTests: XCTestCase { - #if swift(>=5.7) - func testNestedBindingState() { - struct BindingTest: ReducerProtocol { - struct State: Equatable { - @BindingState var nested = Nested() - - struct Nested: Equatable { - var field = "" + import ComposableArchitecture + import XCTest + + @MainActor + final class BindingTests: XCTestCase { + #if swift(>=5.7) + func testNestedBindingState() { + struct BindingTest: ReducerProtocol { + struct State: Equatable { + @BindingState var nested = Nested() + + struct Nested: Equatable { + var field = "" + } } - } - enum Action: BindableAction, Equatable { - case binding(BindingAction) - } + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } - var body: some ReducerProtocol { - BindingReducer() - Reduce { state, action in - switch action { - case .binding(\.$nested.field): - state.nested.field += "!" - return .none - default: - return .none + var body: some ReducerProtocol { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.$nested.field): + state.nested.field += "!" + return .none + default: + return .none + } } } } - } - let store = Store(initialState: BindingTest.State(), reducer: BindingTest()) + let store = Store(initialState: BindingTest.State(), reducer: BindingTest()) - let viewStore = ViewStore(store, observe: { $0 }) + let viewStore = ViewStore(store, observe: { $0 }) - viewStore.binding(\.$nested.field).wrappedValue = "Hello" + viewStore.binding(\.$nested.field).wrappedValue = "Hello" - XCTAssertEqual(viewStore.state, .init(nested: .init(field: "Hello!"))) - } - #endif - - // NB: This crashes in Swift(<5.8) RELEASE when `BindingAction` holds directly onto an unboxed - // `value: Any` existential - func testLayoutBug() { - enum Foo { - case bar(Baz) -} - enum Baz { - case fizz(BindingAction) - case buzz(Bool) + XCTAssertEqual(viewStore.state, .init(nested: .init(field: "Hello!"))) + } + #endif + + // NB: This crashes in Swift(<5.8) RELEASE when `BindingAction` holds directly onto an unboxed + // `value: Any` existential + func testLayoutBug() { + enum Foo { + case bar(Baz) + } + enum Baz { + case fizz(BindingAction) + case buzz(Bool) + } + _ = (/Foo.bar).extract(from: .bar(.buzz(true))) } - _ = (/Foo.bar).extract(from: .bar(.buzz(true))) } -} #endif diff --git a/Tests/ComposableArchitectureTests/EffectCancellationTests.swift b/Tests/ComposableArchitectureTests/EffectCancellationTests.swift index a2c8d536d..f680ffc6c 100644 --- a/Tests/ComposableArchitectureTests/EffectCancellationTests.swift +++ b/Tests/ComposableArchitectureTests/EffectCancellationTests.swift @@ -68,15 +68,17 @@ final class EffectCancellationTests: XCTestCase { func testCancellationAfterDelay() { var value: Int? + let scheduler = QueueScheduler() + Effect(value: 1) - .deferred(for: 0.5, scheduler: QueueScheduler.main) + .deferred(for: 0.5, scheduler: scheduler) .cancellable(id: CancelID()) .producer .startWithValues { value = $0 } XCTAssertEqual(value, nil) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + scheduler.queue.asyncAfter(deadline: .now() + 0.05) { _ = EffectTask.cancel(id: CancelID()) .producer .start() diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index 4e69f2912..234367534 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -206,8 +206,6 @@ reducer: EmptyReducer() ) - let viewStore = ViewStore(store) - var line: UInt = 0 XCTExpectFailure { line = #line diff --git a/Tests/ComposableArchitectureTests/StoreFilterTests.swift b/Tests/ComposableArchitectureTests/StoreFilterTests.swift index 63ea7a3ad..a64eb760b 100644 --- a/Tests/ComposableArchitectureTests/StoreFilterTests.swift +++ b/Tests/ComposableArchitectureTests/StoreFilterTests.swift @@ -1,12 +1,12 @@ -#if DEBUG - import Combine +// `@MainActor` introduces issues gathering tests on Linux +#if DEBUG && !os(Linux) + import ReactiveSwift import XCTest @testable import ComposableArchitecture @MainActor final class StoreFilterTests: XCTestCase { - var cancellables: Set = [] func testFilter() { let store = Store(initialState: nil, reducer: EmptyReducer()) @@ -14,9 +14,8 @@ let viewStore = ViewStore(store) var count = 0 - viewStore.publisher - .sink { _ in count += 1 } - .store(in: &self.cancellables) + viewStore.produced.producer + .startWithValues { _ in count += 1 } XCTAssertEqual(count, 1) viewStore.send(()) diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index 38c09546b..7e6b1fad6 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -4,8 +4,8 @@ import XCTest // `@MainActor` introduces issues gathering tests on Linux #if !os(Linux) -@MainActor -final class StoreTests: XCTestCase { + @MainActor + final class StoreTests: XCTestCase { func testProducedMapping() { struct ChildState: Equatable { @@ -39,554 +39,554 @@ final class StoreTests: XCTestCase { } #if DEBUG - func testCancellableIsRemovedOnImmediatelyCompletingEffect() { - let store = Store(initialState: (), reducer: EmptyReducer()) + func testCancellableIsRemovedOnImmediatelyCompletingEffect() { + let store = Store(initialState: (), reducer: EmptyReducer()) XCTAssertEqual(store.effectDisposables.count, 0) - _ = store.send(()) + _ = store.send(()) XCTAssertEqual(store.effectDisposables.count, 0) - } + } #endif - func testCancellableIsRemovedWhenEffectCompletes() { + func testCancellableIsRemovedWhenEffectCompletes() { let mainQueue = TestScheduler() - let effect = EffectTask(value: ()) + let effect = EffectTask(value: ()) .deferred(for: 1, scheduler: mainQueue) - enum Action { case start, end } + enum Action { case start, end } - let reducer = Reduce({ _, action in - switch action { - case .start: - return effect.map { .end } - case .end: - return .none - } - }) - let store = Store(initialState: (), reducer: reducer) + let reducer = Reduce({ _, action in + switch action { + case .start: + return effect.map { .end } + case .end: + return .none + } + }) + let store = Store(initialState: (), reducer: reducer) XCTAssertEqual(store.effectDisposables.count, 0) - _ = store.send(.start) + _ = store.send(.start) XCTAssertEqual(store.effectDisposables.count, 1) - mainQueue.advance(by: 2) + mainQueue.advance(by: 2) XCTAssertEqual(store.effectDisposables.count, 0) - } + } - func testScopedStoreReceivesUpdatesFromParent() { - let counterReducer = Reduce({ state, _ in - state += 1 - return .none - }) + func testScopedStoreReceivesUpdatesFromParent() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) - let parentStore = Store(initialState: 0, reducer: counterReducer) - let parentViewStore = ViewStore(parentStore) - let childStore = parentStore.scope(state: String.init) + let parentStore = Store(initialState: 0, reducer: counterReducer) + let parentViewStore = ViewStore(parentStore) + let childStore = parentStore.scope(state: String.init) - var values: [String] = [] + var values: [String] = [] let childViewStore = ViewStore(childStore) childViewStore.produced.producer .startWithValues { values.append($0) } - XCTAssertEqual(values, ["0"]) + XCTAssertEqual(values, ["0"]) - parentViewStore.send(()) + parentViewStore.send(()) - XCTAssertEqual(values, ["0", "1"]) - } + XCTAssertEqual(values, ["0", "1"]) + } - func testParentStoreReceivesUpdatesFromChild() { - let counterReducer = Reduce({ state, _ in - state += 1 - return .none - }) + func testParentStoreReceivesUpdatesFromChild() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) - let parentStore = Store(initialState: 0, reducer: counterReducer) - let childStore = parentStore.scope(state: String.init) - let childViewStore = ViewStore(childStore) + let parentStore = Store(initialState: 0, reducer: counterReducer) + let childStore = parentStore.scope(state: String.init) + let childViewStore = ViewStore(childStore) - var values: [Int] = [] + var values: [Int] = [] let parentViewStore = ViewStore(parentStore) parentViewStore.produced.producer .startWithValues { values.append($0) } - XCTAssertEqual(values, [0]) + XCTAssertEqual(values, [0]) - childViewStore.send(()) - - XCTAssertEqual(values, [0, 1]) - } + childViewStore.send(()) - func testScopeCallCount() { - let counterReducer = Reduce({ state, _ in - state += 1 - return .none - }) - - var numCalls1 = 0 - _ = Store(initialState: 0, reducer: counterReducer) - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) - - XCTAssertEqual(numCalls1, 1) - } + XCTAssertEqual(values, [0, 1]) + } - func testScopeCallCount2() { - let counterReducer = Reduce({ state, _ in - state += 1 - return .none - }) - - var numCalls1 = 0 - var numCalls2 = 0 - var numCalls3 = 0 - - let store1 = Store(initialState: 0, reducer: counterReducer) - let store2 = - store1 - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) - let store3 = - store2 - .scope(state: { (count: Int) -> Int in - numCalls2 += 1 - return count - }) - let store4 = - store3 - .scope(state: { (count: Int) -> Int in - numCalls3 += 1 - return count + func testScopeCallCount() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none }) - let viewStore1 = ViewStore(store1) - let viewStore2 = ViewStore(store2) - let viewStore3 = ViewStore(store3) - let viewStore4 = ViewStore(store4) - - XCTAssertEqual(numCalls1, 1) - XCTAssertEqual(numCalls2, 1) - XCTAssertEqual(numCalls3, 1) - - viewStore4.send(()) - - XCTAssertEqual(numCalls1, 2) - XCTAssertEqual(numCalls2, 2) - XCTAssertEqual(numCalls3, 2) + var numCalls1 = 0 + _ = Store(initialState: 0, reducer: counterReducer) + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) - viewStore4.send(()) - - XCTAssertEqual(numCalls1, 3) - XCTAssertEqual(numCalls2, 3) - XCTAssertEqual(numCalls3, 3) - - viewStore4.send(()) - - XCTAssertEqual(numCalls1, 4) - XCTAssertEqual(numCalls2, 4) - XCTAssertEqual(numCalls3, 4) - - viewStore4.send(()) - - XCTAssertEqual(numCalls1, 5) - XCTAssertEqual(numCalls2, 5) - XCTAssertEqual(numCalls3, 5) + XCTAssertEqual(numCalls1, 1) + } - _ = viewStore1 - _ = viewStore2 - _ = viewStore3 - } + func testScopeCallCount2() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) - func testSynchronousEffectsSentAfterSinking() { - enum Action { - case tap - case next1 - case next2 - case end + var numCalls1 = 0 + var numCalls2 = 0 + var numCalls3 = 0 + + let store1 = Store(initialState: 0, reducer: counterReducer) + let store2 = + store1 + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) + let store3 = + store2 + .scope(state: { (count: Int) -> Int in + numCalls2 += 1 + return count + }) + let store4 = + store3 + .scope(state: { (count: Int) -> Int in + numCalls3 += 1 + return count + }) + + let viewStore1 = ViewStore(store1) + let viewStore2 = ViewStore(store2) + let viewStore3 = ViewStore(store3) + let viewStore4 = ViewStore(store4) + + XCTAssertEqual(numCalls1, 1) + XCTAssertEqual(numCalls2, 1) + XCTAssertEqual(numCalls3, 1) + + viewStore4.send(()) + + XCTAssertEqual(numCalls1, 2) + XCTAssertEqual(numCalls2, 2) + XCTAssertEqual(numCalls3, 2) + + viewStore4.send(()) + + XCTAssertEqual(numCalls1, 3) + XCTAssertEqual(numCalls2, 3) + XCTAssertEqual(numCalls3, 3) + + viewStore4.send(()) + + XCTAssertEqual(numCalls1, 4) + XCTAssertEqual(numCalls2, 4) + XCTAssertEqual(numCalls3, 4) + + viewStore4.send(()) + + XCTAssertEqual(numCalls1, 5) + XCTAssertEqual(numCalls2, 5) + XCTAssertEqual(numCalls3, 5) + + _ = viewStore1 + _ = viewStore2 + _ = viewStore3 } - var values: [Int] = [] - let counterReducer = Reduce({ state, action in - switch action { - case .tap: - return .merge( - EffectTask(value: .next1), - EffectTask(value: .next2), + + func testSynchronousEffectsSentAfterSinking() { + enum Action { + case tap + case next1 + case next2 + case end + } + var values: [Int] = [] + let counterReducer = Reduce({ state, action in + switch action { + case .tap: + return .merge( + EffectTask(value: .next1), + EffectTask(value: .next2), Effect.fireAndForget { values.append(1) } - ) - case .next1: - return .merge( - EffectTask(value: .end), + ) + case .next1: + return .merge( + EffectTask(value: .end), Effect.fireAndForget { values.append(2) } - ) - case .next2: - return .fireAndForget { values.append(3) } - case .end: - return .fireAndForget { values.append(4) } - } - }) - - let store = Store(initialState: (), reducer: counterReducer) + ) + case .next2: + return .fireAndForget { values.append(3) } + case .end: + return .fireAndForget { values.append(4) } + } + }) - _ = ViewStore(store, observe: {}, removeDuplicates: ==).send(.tap) + let store = Store(initialState: (), reducer: counterReducer) - XCTAssertEqual(values, [1, 2, 3, 4]) - } + _ = ViewStore(store, observe: {}, removeDuplicates: ==).send(.tap) - func testLotsOfSynchronousActions() { - enum Action { case incr, noop } - let reducer = Reduce({ state, action in - switch action { - case .incr: - state += 1 - return state >= 100_000 ? EffectTask(value: .noop) : EffectTask(value: .incr) - case .noop: - return .none - } - }) + XCTAssertEqual(values, [1, 2, 3, 4]) + } - let store = Store(initialState: 0, reducer: reducer) - _ = ViewStore(store, observe: { $0 }).send(.incr) - XCTAssertEqual(ViewStore(store, observe: { $0 }).state, 100_000) - } + func testLotsOfSynchronousActions() { + enum Action { case incr, noop } + let reducer = Reduce({ state, action in + switch action { + case .incr: + state += 1 + return state >= 100_000 ? EffectTask(value: .noop) : EffectTask(value: .incr) + case .noop: + return .none + } + }) - func testIfLetAfterScope() { - struct AppState: Equatable { - var count: Int? + let store = Store(initialState: 0, reducer: reducer) + _ = ViewStore(store, observe: { $0 }).send(.incr) + XCTAssertEqual(ViewStore(store, observe: { $0 }).state, 100_000) } - let appReducer = Reduce({ state, action in - state.count = action - return .none - }) + func testIfLetAfterScope() { + struct AppState: Equatable { + var count: Int? + } - let parentStore = Store(initialState: AppState(), reducer: appReducer) - let parentViewStore = ViewStore(parentStore) + let appReducer = Reduce({ state, action in + state.count = action + return .none + }) - // NB: This test needs to hold a strong reference to the emitted stores - var outputs: [Int?] = [] - var stores: [Any] = [] + let parentStore = Store(initialState: AppState(), reducer: appReducer) + let parentViewStore = ViewStore(parentStore) + + // NB: This test needs to hold a strong reference to the emitted stores + var outputs: [Int?] = [] + var stores: [Any] = [] - parentStore + parentStore .scope(state: \.count) - .ifLet( - then: { store in - stores.append(store) - outputs.append(ViewStore(store, observe: { $0 }).state) - }, - else: { - outputs.append(nil) + .ifLet( + then: { store in + stores.append(store) + outputs.append(ViewStore(store, observe: { $0 }).state) + }, + else: { + outputs.append(nil) }) - XCTAssertEqual(outputs, [nil]) + XCTAssertEqual(outputs, [nil]) - _ = parentViewStore.send(1) - XCTAssertEqual(outputs, [nil, 1]) + _ = parentViewStore.send(1) + XCTAssertEqual(outputs, [nil, 1]) - _ = parentViewStore.send(nil) - XCTAssertEqual(outputs, [nil, 1, nil]) + _ = parentViewStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil]) - _ = parentViewStore.send(1) - XCTAssertEqual(outputs, [nil, 1, nil, 1]) + _ = parentViewStore.send(1) + XCTAssertEqual(outputs, [nil, 1, nil, 1]) - _ = parentViewStore.send(nil) - XCTAssertEqual(outputs, [nil, 1, nil, 1, nil]) + _ = parentViewStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil]) - _ = parentViewStore.send(1) - XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1]) + _ = parentViewStore.send(1) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1]) - _ = parentViewStore.send(nil) - XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) - } + _ = parentViewStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) + } - func testIfLetTwo() { - let parentStore = Store( - initialState: 0, - reducer: Reduce({ state, action in - if action { - state? += 1 - return .none - } else { - return .task { true } - } - }) - ) + func testIfLetTwo() { + let parentStore = Store( + initialState: 0, + reducer: Reduce({ state, action in + if action { + state? += 1 + return .none + } else { + return .task { true } + } + }) + ) - parentStore - .ifLet(then: { childStore in - let vs = ViewStore(childStore) + parentStore + .ifLet(then: { childStore in + let vs = ViewStore(childStore) - vs + vs .produced.producer .startWithValues { _ in } - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - XCTAssertEqual(vs.state, 3) - }) - } + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + XCTAssertEqual(vs.state, 3) + }) + } - func testActionQueuing() async { + func testActionQueuing() async { let subject = Signal.pipe() - enum Action: Equatable { - case incrementTapped - case `init` - case doIncrement - } + enum Action: Equatable { + case incrementTapped + case `init` + case doIncrement + } - let store = TestStore( - initialState: 0, - reducer: Reduce({ state, action in - switch action { - case .incrementTapped: + let store = TestStore( + initialState: 0, + reducer: Reduce({ state, action in + switch action { + case .incrementTapped: subject.input.send(value: ()) - return .none + return .none - case .`init`: + case .`init`: return subject.output.producer .map { .doIncrement } .eraseToEffect() - case .doIncrement: - state += 1 - return .none - } - }) - ) + case .doIncrement: + state += 1 + return .none + } + }) + ) - await store.send(.`init`) - await store.send(.incrementTapped) - await store.receive(.doIncrement) { - $0 = 1 - } - await store.send(.incrementTapped) - await store.receive(.doIncrement) { - $0 = 2 - } + await store.send(.`init`) + await store.send(.incrementTapped) + await store.receive(.doIncrement) { + $0 = 1 + } + await store.send(.incrementTapped) + await store.receive(.doIncrement) { + $0 = 2 + } subject.input.sendCompleted() - } + } - func testCoalesceSynchronousActions() { - let store = Store( - initialState: 0, - reducer: Reduce({ state, action in - switch action { - case 0: - return .merge( - EffectTask(value: 1), - EffectTask(value: 2), - EffectTask(value: 3) - ) - default: - state = action - return .none - } - }) - ) + func testCoalesceSynchronousActions() { + let store = Store( + initialState: 0, + reducer: Reduce({ state, action in + switch action { + case 0: + return .merge( + EffectTask(value: 1), + EffectTask(value: 2), + EffectTask(value: 3) + ) + default: + state = action + return .none + } + }) + ) - var emissions: [Int] = [] - let viewStore = ViewStore(store, observe: { $0 }) + var emissions: [Int] = [] + let viewStore = ViewStore(store, observe: { $0 }) viewStore.produced.producer .startWithValues { emissions.append($0) } - XCTAssertEqual(emissions, [0]) + XCTAssertEqual(emissions, [0]) - viewStore.send(0) + viewStore.send(0) - XCTAssertEqual(emissions, [0, 3]) - } - - func testBufferedActionProcessing() { - struct ChildState: Equatable { - var count: Int? + XCTAssertEqual(emissions, [0, 3]) } - struct ParentState: Equatable { - var count: Int? - var child: ChildState? - } - - enum ParentAction: Equatable { - case button - case child(Int?) - } - - var handledActions: [ParentAction] = [] - let parentReducer = Reduce({ state, action in - handledActions.append(action) + func testBufferedActionProcessing() { + struct ChildState: Equatable { + var count: Int? + } - switch action { - case .button: - state.child = .init(count: nil) - return .none + struct ParentState: Equatable { + var count: Int? + var child: ChildState? + } - case .child(let childCount): - state.count = childCount - return .none + enum ParentAction: Equatable { + case button + case child(Int?) } - }) - .ifLet(\.child, action: /ParentAction.child) { - Reduce({ state, action in - state.count = action - return .none - }) - } - let parentStore = Store( - initialState: ParentState(), - reducer: parentReducer - ) + var handledActions: [ParentAction] = [] + let parentReducer = Reduce({ state, action in + handledActions.append(action) - parentStore - .scope( - state: \.child, - action: ParentAction.child - ) - .ifLet { childStore in - ViewStore(childStore).send(2) - } + switch action { + case .button: + state.child = .init(count: nil) + return .none - XCTAssertEqual(handledActions, []) + case .child(let childCount): + state.count = childCount + return .none + } + }) + .ifLet(\.child, action: /ParentAction.child) { + Reduce({ state, action in + state.count = action + return .none + }) + } - _ = ViewStore(parentStore).send(.button) - XCTAssertEqual( - handledActions, - [ - .button, - .child(2), - ]) - } + let parentStore = Store( + initialState: ParentState(), + reducer: parentReducer + ) - func testCascadingTaskCancellation() async { - enum Action { case task, response, response1, response2 } - let reducer = Reduce({ state, action in - switch action { - case .task: - return .task { .response } - case .response: - return .merge( - SignalProducer { _, _ in }.eraseToEffect(), - .task { .response1 } - ) - case .response1: - return .merge( - SignalProducer { _, _ in }.eraseToEffect(), - .task { .response2 } + parentStore + .scope( + state: \.child, + action: ParentAction.child ) - case .response2: - return SignalProducer { _, _ in }.eraseToEffect() - } - }) - - let store = TestStore( - initialState: 0, - reducer: reducer - ) - - let task = await store.send(.task) - await store.receive(.response) - await store.receive(.response1) - await store.receive(.response2) - await task.cancel() - } + .ifLet { childStore in + ViewStore(childStore).send(2) + } - func testTaskCancellationEmpty() async { - enum Action { case task } + XCTAssertEqual(handledActions, []) - let store = TestStore( - initialState: 0, - reducer: Reduce({ state, action in + _ = ViewStore(parentStore).send(.button) + XCTAssertEqual( + handledActions, + [ + .button, + .child(2), + ]) + } + + func testCascadingTaskCancellation() async { + enum Action { case task, response, response1, response2 } + let reducer = Reduce({ state, action in switch action { case .task: - return .fireAndForget { try await Task.never() } + return .task { .response } + case .response: + return .merge( + SignalProducer { _, _ in }.eraseToEffect(), + .task { .response1 } + ) + case .response1: + return .merge( + SignalProducer { _, _ in }.eraseToEffect(), + .task { .response2 } + ) + case .response2: + return SignalProducer { _, _ in }.eraseToEffect() } }) - ) - await store.send(.task).cancel() - } + let store = TestStore( + initialState: 0, + reducer: reducer + ) - func testScopeCancellation() async throws { - let neverEndingTask = Task { try await Task.never() } + let task = await store.send(.task) + await store.receive(.response) + await store.receive(.response1) + await store.receive(.response2) + await task.cancel() + } - let store = Store( - initialState: (), - reducer: Reduce({ _, _ in - .fireAndForget { - try await neverEndingTask.value - } - }) - ) - let scopedStore = store.scope(state: { $0 }) + func testTaskCancellationEmpty() async { + enum Action { case task } + + let store = TestStore( + initialState: 0, + reducer: Reduce({ state, action in + switch action { + case .task: + return .fireAndForget { try await Task.never() } + } + }) + ) + + await store.send(.task).cancel() + } + + func testScopeCancellation() async throws { + let neverEndingTask = Task { try await Task.never() } + + let store = Store( + initialState: (), + reducer: Reduce({ _, _ in + .fireAndForget { + try await neverEndingTask.value + } + }) + ) + let scopedStore = store.scope(state: { $0 }) - let sendTask = scopedStore.send(()) - await Task.yield() - neverEndingTask.cancel() - try await XCTUnwrap(sendTask).value + let sendTask = scopedStore.send(()) + await Task.yield() + neverEndingTask.cancel() + try await XCTUnwrap(sendTask).value XCTAssertEqual(store.effectDisposables.count, 0) XCTAssertEqual(scopedStore.effectDisposables.count, 0) - } + } - func testOverrideDependenciesDirectlyOnReducer() { - 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 + func testOverrideDependenciesDirectlyOnReducer() { + 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 = Store( + initialState: 0, + reducer: Counter() + .dependency(\.calendar, Calendar(identifier: .gregorian)) + .dependency(\.locale, Locale(identifier: "en_US")) + .dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!) + .dependency(\.urlSession, URLSession(configuration: .ephemeral)) + ) + + ViewStore(store, observe: { $0 }).send(true) } - let store = Store( - initialState: 0, - reducer: Counter() - .dependency(\.calendar, Calendar(identifier: .gregorian)) - .dependency(\.locale, Locale(identifier: "en_US")) - .dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!) - .dependency(\.urlSession, URLSession(configuration: .ephemeral)) - ) + func testOverrideDependenciesDirectlyOnStore() { + struct MyReducer: ReducerProtocol { + @Dependency(\.uuid) var uuid - ViewStore(store, observe: { $0 }).send(true) - } + func reduce(into state: inout UUID, action: Void) -> EffectTask { + state = self.uuid() + return .none + } + } - func testOverrideDependenciesDirectlyOnStore() { - struct MyReducer: ReducerProtocol { @Dependency(\.uuid) var uuid - func reduce(into state: inout UUID, action: Void) -> EffectTask { - state = self.uuid() - return .none + let store = Store(initialState: uuid(), reducer: MyReducer()) { + $0.uuid = .constant(UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) } - } + let viewStore = ViewStore(store, observe: { $0 }) - @Dependency(\.uuid) var uuid - - let store = Store(initialState: uuid(), reducer: MyReducer()) { - $0.uuid = .constant(UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) + XCTAssertEqual(viewStore.state, UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) } - let viewStore = ViewStore(store, observe: { $0 }) - - XCTAssertEqual(viewStore.state, UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) } -} #endif