diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectSend.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectSend.md index 132f94b7f..f3919a590 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectSend.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectSend.md @@ -1,7 +1,7 @@ -# ``ComposableArchitecture/EffectPublisher/send(_:)`` +# ``ComposableArchitecture/EffectProducer/send(_:)`` ## Topics ### Animating actions -- ``EffectPublisher/send(_:animation:)`` +- ``EffectProducer/send(_:animation:)`` diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index 5652139dd..045c220d1 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -3,7 +3,7 @@ import ReactiveSwift import XCTestDynamicOverlay #if canImport(SwiftUI) -import SwiftUI + import SwiftUI #endif /// This type is deprecated in favor of ``EffectTask``. See its documentation for more information. @@ -115,7 +115,7 @@ extension EffectProducer { /// > This is only an issue if using the Combine interface of ``EffectProducer`` as mentioned /// > above. If you are using Swift's concurrency tools and the `.task`, `.run`, and /// > `.fireAndForget` functions on ``EffectTask``, then threading is automatically handled for you. -public typealias EffectTask = EffectPublisher +public typealias EffectTask = EffectProducer extension EffectProducer where Failure == Never { /// Wraps an asynchronous unit of work in an effect. @@ -340,20 +340,22 @@ extension EffectProducer where Failure == Never { Self(value: action) } - /// Initializes an effect that immediately emits the action passed in. - /// - /// > Note: We do not recommend using `Effect.send` to share logic. Instead, limit usage to - /// > child-parent communication, where a child may want to emit a "delegate" action for a parent - /// > to listen to. - /// > - /// > For more information, see . - /// - /// - Parameters: - /// - action: The action that is immediately emitted by the effect. - /// - animation: An animation. - public static func send(_ action: Action, animation: Animation? = nil) -> Self { - Self(value: action).animation(animation) - } + #if canImport(SwiftUI) + /// Initializes an effect that immediately emits the action passed in. + /// + /// > Note: We do not recommend using `Effect.send` to share logic. Instead, limit usage to + /// > child-parent communication, where a child may want to emit a "delegate" action for a parent + /// > to listen to. + /// > + /// > For more information, see . + /// + /// - Parameters: + /// - action: The action that is immediately emitted by the effect. + /// - animation: An animation. + public static func send(_ action: Action, animation: Animation? = nil) -> Self { + Self(value: action).animation(animation) + } + #endif } /// A type that can send actions back into the system when used from @@ -401,15 +403,15 @@ public struct Send { } #if canImport(SwiftUI) - /// Sends an action back into the system from an effect with animation. - /// - /// - Parameters: - /// - action: An action. - /// - animation: An animation. - public func callAsFunction(_ action: Action, animation: Animation?) { - callAsFunction(action, transaction: Transaction(animation: animation)) - } - + /// Sends an action back into the system from an effect with animation. + /// + /// - Parameters: + /// - action: An action. + /// - animation: An animation. + public func callAsFunction(_ action: Action, animation: Animation?) { + callAsFunction(action, transaction: Transaction(animation: animation)) + } + /// Sends an action back into the system from an effect with transaction. /// /// - Parameters: diff --git a/Sources/ComposableArchitecture/Effects/Animation.swift b/Sources/ComposableArchitecture/Effects/Animation.swift index 4a691ffeb..9606f9e0f 100644 --- a/Sources/ComposableArchitecture/Effects/Animation.swift +++ b/Sources/ComposableArchitecture/Effects/Animation.swift @@ -1,44 +1,44 @@ #if canImport(SwiftUI) import ReactiveSwift -import SwiftUI + import SwiftUI extension EffectProducer { - /// Wraps the emission of each element with SwiftUI's `withAnimation`. - /// - /// ```swift - /// case .buttonTapped: - /// return .task { - /// .activityResponse(await self.apiClient.fetchActivity()) - /// } - /// .animation() - /// ``` - /// - /// - Parameter animation: An animation. + /// Wraps the emission of each element with SwiftUI's `withAnimation`. + /// + /// ```swift + /// case .buttonTapped: + /// return .task { + /// .activityResponse(await self.apiClient.fetchActivity()) + /// } + /// .animation() + /// ``` + /// + /// - Parameter animation: An animation. /// - Returns: An effect. - public func animation(_ animation: Animation? = .default) -> Self { - self.transaction(Transaction(animation: animation)) - } + public func animation(_ animation: Animation? = .default) -> Self { + self.transaction(Transaction(animation: animation)) + } - /// Wraps the emission of each element with SwiftUI's `withTransaction`. - /// - /// ```swift - /// case .buttonTapped: - /// var transaction = Transaction(animation: .default) - /// transaction.disablesAnimations = true - /// return .task { - /// .activityResponse(await self.apiClient.fetchActivity()) - /// } - /// .transaction(transaction) - /// ``` - /// - /// - Parameter transaction: A transaction. - /// - Returns: A publisher. - public func transaction(_ transaction: Transaction) -> Self { - switch self.operation { - case .none: - return .none + /// Wraps the emission of each element with SwiftUI's `withTransaction`. + /// + /// ```swift + /// case .buttonTapped: + /// var transaction = Transaction(animation: .default) + /// transaction.disablesAnimations = true + /// return .task { + /// .activityResponse(await self.apiClient.fetchActivity()) + /// } + /// .transaction(transaction) + /// ``` + /// + /// - Parameter transaction: A transaction. + /// - Returns: A publisher. + public func transaction(_ transaction: Transaction) -> Self { + switch self.operation { + case .none: + return .none case let .producer(producer): - return Self( + return Self( operation: .producer( SignalProducer { observer, _ in producer.start { action in @@ -56,21 +56,21 @@ import SwiftUI } } } + ) ) - ) - case let .run(priority, operation): - return Self( - operation: .run(priority) { send in - await operation( - Send { value in - withTransaction(transaction) { - send(value) + case let .run(priority, operation): + return Self( + operation: .run(priority) { send in + await operation( + Send { value in + withTransaction(transaction) { + send(value) + } } - } - ) - } - ) + ) + } + ) + } } } -} #endif diff --git a/Sources/ComposableArchitecture/Effects/Publisher/Timer.swift b/Sources/ComposableArchitecture/Effects/Publisher/Timer.swift index d79ce38e4..57f272f4c 100644 --- a/Sources/ComposableArchitecture/Effects/Publisher/Timer.swift +++ b/Sources/ComposableArchitecture/Effects/Publisher/Timer.swift @@ -49,7 +49,7 @@ extension EffectProducer where Action == Date, Failure == Never { /// initialState: Feature.State(), /// reducer: Feature() /// ) { - /// $0.mainQueue = mainQueue + /// $0.mainQueueScheduler = mainQueue /// } /// /// await store.send(.startButtonTapped) diff --git a/Sources/ComposableArchitecture/Internal/Deprecations.swift b/Sources/ComposableArchitecture/Internal/Deprecations.swift index 849b25616..56162b989 100644 --- a/Sources/ComposableArchitecture/Internal/Deprecations.swift +++ b/Sources/ComposableArchitecture/Internal/Deprecations.swift @@ -3,7 +3,7 @@ import ReactiveSwift import XCTestDynamicOverlay #if canImport(SwiftUI) -import SwiftUI + import SwiftUI #endif #if DEBUG && canImport(os) @@ -45,16 +45,16 @@ extension ActorIsolated { // MARK: - Deprecated after 0.45.0: #if canImport(SwiftUI) -@available( - *, - deprecated, - message: "Pass 'TextState' to the 'SwiftUI.Text' initializer, instead, e.g., 'Text(textState)'." -) -extension TextState: View { - public var body: some View { - Text(self) + @available( + *, + deprecated, + message: "Pass 'TextState' to the 'SwiftUI.Text' initializer, instead, e.g., 'Text(textState)'." + ) + extension TextState: View { + public var body: some View { + Text(self) + } } -} #endif // MARK: - Deprecated after 0.42.0: @@ -121,416 +121,416 @@ extension ReducerProtocol { // MARK: - Deprecated after 0.40.0: #if canImport(SwiftUI) -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -extension WithViewStore: AccessibilityRotorContent where Content: AccessibilityRotorContent { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute accessibility rotor content from store state. - /// - /// - Parameters: - /// - store: A store. - /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values - /// are equal, repeat view computations are removed, - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension WithViewStore: AccessibilityRotorContent where Content: AccessibilityRotorContent { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute accessibility rotor content from store state. + /// + /// - Parameters: + /// - store: A store. + /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values + /// are equal, repeat view computations are removed, + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from an accessibility rotor content builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool, - @AccessibilityRotorContentBuilder content: @escaping (ViewStore) -> - Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.init( - store: store, - removeDuplicates: isDuplicate, - content: content, - file: file, - line: line ) + public init( + _ store: Store, + removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool, + @AccessibilityRotorContentBuilder content: @escaping (ViewStore) -> + Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + store: store, + removeDuplicates: isDuplicate, + content: content, + file: file, + line: line + ) + } } -} -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -extension WithViewStore where ViewState: Equatable, Content: AccessibilityRotorContent { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute accessibility rotor content from equatable store state. - /// - /// - Parameters: - /// - store: A store of equatable state. - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension WithViewStore where ViewState: Equatable, Content: AccessibilityRotorContent { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute accessibility rotor content from equatable store state. + /// + /// - Parameters: + /// - store: A store of equatable state. + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from an accessibility rotor content builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - @AccessibilityRotorContentBuilder content: @escaping (ViewStore) -> - Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + ) + public init( + _ store: Store, + @AccessibilityRotorContentBuilder content: @escaping (ViewStore) -> + Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + } } -} -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -extension WithViewStore where ViewState == Void, Content: AccessibilityRotorContent { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute accessibility rotor content from void store state. - /// - /// - Parameters: - /// - store: A store of equatable state. - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension WithViewStore where ViewState == Void, Content: AccessibilityRotorContent { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute accessibility rotor content from void store state. + /// + /// - Parameters: + /// - store: A store of equatable state. + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from an accessibility rotor content builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - file: StaticString = #fileID, - line: UInt = #line, - @AccessibilityRotorContentBuilder content: @escaping (ViewStore) -> - Content - ) { - self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + ) + public init( + _ store: Store, + file: StaticString = #fileID, + line: UInt = #line, + @AccessibilityRotorContentBuilder content: @escaping (ViewStore) -> + Content + ) { + self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + } } -} -@available(iOS 14, macOS 11, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension WithViewStore: Commands where Content: Commands { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute commands from store state. - /// - /// - Parameters: - /// - store: A store. - /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values - /// are equal, repeat view computations are removed, - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 14, macOS 11, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension WithViewStore: Commands where Content: Commands { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute commands from store state. + /// + /// - Parameters: + /// - store: A store. + /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values + /// are equal, repeat view computations are removed, + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from a command builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool, - @CommandsBuilder content: @escaping (ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.init( - store: store, - removeDuplicates: isDuplicate, - content: content, - file: file, - line: line ) + public init( + _ store: Store, + removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool, + @CommandsBuilder content: @escaping (ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + store: store, + removeDuplicates: isDuplicate, + content: content, + file: file, + line: line + ) + } } -} -@available(iOS 14, macOS 11, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension WithViewStore where ViewState: Equatable, Content: Commands { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute commands from equatable store state. - /// - /// - Parameters: - /// - store: A store of equatable state. - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 14, macOS 11, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension WithViewStore where ViewState: Equatable, Content: Commands { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute commands from equatable store state. + /// + /// - Parameters: + /// - store: A store of equatable state. + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from a command builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - @CommandsBuilder content: @escaping (ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + ) + public init( + _ store: Store, + @CommandsBuilder content: @escaping (ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + } } -} -@available(iOS 14, macOS 11, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension WithViewStore where ViewState == Void, Content: Commands { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute commands from void store state. - /// - /// - Parameters: - /// - store: A store of equatable state. - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 14, macOS 11, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension WithViewStore where ViewState == Void, Content: Commands { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute commands from void store state. + /// + /// - Parameters: + /// - store: A store of equatable state. + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from a command builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - file: StaticString = #fileID, - line: UInt = #line, - @CommandsBuilder content: @escaping (ViewStore) -> Content - ) { - self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + ) + public init( + _ store: Store, + file: StaticString = #fileID, + line: UInt = #line, + @CommandsBuilder content: @escaping (ViewStore) -> Content + ) { + self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + } } -} -@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) -extension WithViewStore: Scene where Content: Scene { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute scenes from store state. - /// - /// - Parameters: - /// - store: A store. - /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values - /// are equal, repeat view computations are removed, - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension WithViewStore: Scene where Content: Scene { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute scenes from store state. + /// + /// - Parameters: + /// - store: A store. + /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values + /// are equal, repeat view computations are removed, + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from a scene builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool, - @SceneBuilder content: @escaping (ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.init( - store: store, - removeDuplicates: isDuplicate, - content: content, - file: file, - line: line ) + public init( + _ store: Store, + removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool, + @SceneBuilder content: @escaping (ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + store: store, + removeDuplicates: isDuplicate, + content: content, + file: file, + line: line + ) + } } -} -@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) -extension WithViewStore where ViewState: Equatable, Content: Scene { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute scenes from equatable store state. - /// - /// - Parameters: - /// - store: A store of equatable state. - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension WithViewStore where ViewState: Equatable, Content: Scene { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute scenes from equatable store state. + /// + /// - Parameters: + /// - store: A store of equatable state. + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from a scene builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - @SceneBuilder content: @escaping (ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + ) + public init( + _ store: Store, + @SceneBuilder content: @escaping (ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + } } -} -@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) -extension WithViewStore where ViewState == Void, Content: Scene { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute scenes from void store state. - /// - /// - Parameters: - /// - store: A store of equatable state. - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension WithViewStore where ViewState == Void, Content: Scene { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute scenes from void store state. + /// + /// - Parameters: + /// - store: A store of equatable state. + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from a scene builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - file: StaticString = #fileID, - line: UInt = #line, - @SceneBuilder content: @escaping (ViewStore) -> Content - ) { - self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + ) + public init( + _ store: Store, + file: StaticString = #fileID, + line: UInt = #line, + @SceneBuilder content: @escaping (ViewStore) -> Content + ) { + self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + } } -} -@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) -extension WithViewStore: ToolbarContent where Content: ToolbarContent { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute toolbar content from store state. - /// - /// - Parameters: - /// - store: A store. - /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values - /// are equal, repeat view computations are removed, - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension WithViewStore: ToolbarContent where Content: ToolbarContent { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute toolbar content from store state. + /// + /// - Parameters: + /// - store: A store. + /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values + /// are equal, repeat view computations are removed, + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from a toolbar content builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool, - file: StaticString = #fileID, - line: UInt = #line, - @ToolbarContentBuilder content: @escaping (ViewStore) -> Content - ) { - self.init( - store: store, - removeDuplicates: isDuplicate, - content: content, - file: file, - line: line ) + public init( + _ store: Store, + removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool, + file: StaticString = #fileID, + line: UInt = #line, + @ToolbarContentBuilder content: @escaping (ViewStore) -> Content + ) { + self.init( + store: store, + removeDuplicates: isDuplicate, + content: content, + file: file, + line: line + ) + } } -} -@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) -extension WithViewStore where ViewState: Equatable, Content: ToolbarContent { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute toolbar content from equatable store state. - /// - /// - Parameters: - /// - store: A store of equatable state. - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension WithViewStore where ViewState: Equatable, Content: ToolbarContent { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute toolbar content from equatable store state. + /// + /// - Parameters: + /// - store: A store of equatable state. + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from a toolbar content builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - file: StaticString = #fileID, - line: UInt = #line, - @ToolbarContentBuilder content: @escaping (ViewStore) -> Content - ) { - self.init(store, removeDuplicates: ==, file: file, line: line, content: content) + ) + public init( + _ store: Store, + file: StaticString = #fileID, + line: UInt = #line, + @ToolbarContentBuilder content: @escaping (ViewStore) -> Content + ) { + self.init(store, removeDuplicates: ==, file: file, line: line, content: content) + } } -} -@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) -extension WithViewStore where ViewState == Void, Content: ToolbarContent { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute toolbar content from void store state. - /// - /// - Parameters: - /// - store: A store of equatable state. - /// - content: A function that can generate content from a view store. - @available( - *, - deprecated, - message: - """ + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension WithViewStore where ViewState == Void, Content: ToolbarContent { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute toolbar content from void store state. + /// + /// - Parameters: + /// - store: A store of equatable state. + /// - content: A function that can generate content from a view store. + @available( + *, + deprecated, + message: + """ For compiler performance, using "WithViewStore" from a toolbar content builder is no longer supported. Extract this "WithViewStore" to the parent view, instead, or observe your view store from an "@ObservedObject" property. See the documentation for "WithViewStore" (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewstore#overview) for more information. """ - ) - public init( - _ store: Store, - file: StaticString = #fileID, - line: UInt = #line, - @ToolbarContentBuilder content: @escaping (ViewStore) -> Content - ) { - self.init(store, removeDuplicates: ==, file: file, line: line, content: content) + ) + public init( + _ store: Store, + file: StaticString = #fileID, + line: UInt = #line, + @ToolbarContentBuilder content: @escaping (ViewStore) -> Content + ) { + self.init(store, removeDuplicates: ==, file: file, line: line, content: content) + } } -} #endif // MARK: - Deprecated after 0.39.1: #if canImport(SwiftUI) -extension WithViewStore { - @available(*, deprecated, renamed: "ViewState") - public typealias State = ViewState + extension WithViewStore { + @available(*, deprecated, renamed: "ViewState") + public typealias State = ViewState - @available(*, deprecated, renamed: "ViewAction") - public typealias Action = ViewAction -} + @available(*, deprecated, renamed: "ViewAction") + public typealias Action = ViewAction + } #endif // MARK: - Deprecated after 0.39.0: #if canImport(SwiftUI) -extension CaseLet { - @available(*, deprecated, renamed: "EnumState") - public typealias GlobalState = EnumState + extension CaseLet { + @available(*, deprecated, renamed: "EnumState") + public typealias GlobalState = EnumState - @available(*, deprecated, renamed: "EnumAction") - public typealias GlobalAction = EnumAction + @available(*, deprecated, renamed: "EnumAction") + public typealias GlobalAction = EnumAction - @available(*, deprecated, renamed: "CaseState") - public typealias LocalState = CaseState + @available(*, deprecated, renamed: "CaseState") + public typealias LocalState = CaseState - @available(*, deprecated, renamed: "CaseAction") - public typealias LocalAction = CaseAction -} + @available(*, deprecated, renamed: "CaseAction") + public typealias LocalAction = CaseAction + } #endif extension TestStore { @@ -558,19 +558,19 @@ extension EffectProducer where Failure == Error { var task: Task<(), Never>? let producer = SignalProducer { observer, lifetime in task = Task(priority: priority) { @MainActor in - do { - try Task.checkCancellation() - let output = try await operation() - try Task.checkCancellation() + do { + try Task.checkCancellation() + let output = try await operation() + try Task.checkCancellation() observer.send(value: output) observer.sendCompleted() - } catch is CancellationError { + } catch is CancellationError { observer.sendCompleted() - } catch { + } catch { observer.send(error: error) + } } } - } return producer.on(disposed: task?.cancel) } @@ -901,301 +901,301 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { // MARK: - Deprecated after 0.27.1: #if canImport(SwiftUI) -@available(iOS 13, *) -@available(macOS 12, *) -@available(tvOS 13, *) -@available(watchOS 6, *) -@available(*, deprecated, renamed: "ConfirmationDialogState") -public typealias ActionSheetState = ConfirmationDialogState - -extension View { @available(iOS 13, *) @available(macOS 12, *) @available(tvOS 13, *) @available(watchOS 6, *) - @available(*, deprecated, renamed: "confirmationDialog") - public func actionSheet( - _ store: Store?, Action>, - dismiss: Action - ) -> some View { - self.confirmationDialog(store, dismiss: dismiss) + @available(*, deprecated, renamed: "ConfirmationDialogState") + public typealias ActionSheetState = ConfirmationDialogState + + extension View { + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + @available(*, deprecated, renamed: "confirmationDialog") + public func actionSheet( + _ store: Store?, Action>, + dismiss: Action + ) -> some View { + self.confirmationDialog(store, dismiss: dismiss) + } } -} -extension Store { - @available( - *, deprecated, - message: - """ + extension Store { + @available( + *, deprecated, + message: + """ If you use this method, please open a discussion on GitHub and let us know how: \ https://github.com/pointfreeco/swift-composable-architecture/discussions/new """ - ) + ) public func producerScope( state toChildState: @escaping (SignalProducer) -> SignalProducer< ChildState, Never >, - action fromChildAction: @escaping (ChildAction) -> Action + action fromChildAction: @escaping (ChildAction) -> Action ) -> SignalProducer, Never> { - func extractChildState(_ state: State) -> ChildState? { - var childState: ChildState? + func extractChildState(_ state: State) -> ChildState? { + var childState: ChildState? _ = toChildState(SignalProducer(value: state)) .startWithValues { childState = $0 } - return childState - } + return childState + } return toChildState(self.producer) - .map { childState in + .map { childState in let localStore = Store( - initialState: childState, - reducer: .init { childState, childAction, _ in - let task = self.send(fromChildAction(childAction)) + initialState: childState, + reducer: .init { childState, childAction, _ in + let task = self.send(fromChildAction(childAction)) childState = extractChildState(self.state) ?? childState - if let task = task { - return .fireAndForget { await task.cancellableValue } - } else { - return .none - } - }, - environment: () - ) + if let task = task { + return .fireAndForget { await task.cancellableValue } + } else { + return .none + } + }, + environment: () + ) localStore.parentDisposable = self.producer.startWithValues { [weak localStore] state in guard let localStore = localStore else { return } localStore.state = extractChildState(state) ?? localStore.state } return localStore - } - } + } + } - @available( - *, deprecated, - message: - """ + @available( + *, deprecated, + message: + """ If you use this method, please open a discussion on GitHub and let us know how: \ https://github.com/pointfreeco/swift-composable-architecture/discussions/new """ - ) + ) public func producerScope( state toChildState: @escaping (SignalProducer) -> SignalProducer< ChildState, Never > ) -> SignalProducer, Never> { self.producerScope(state: toChildState, action: { $0 }) + } } -} -extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewState { - @available( - *, deprecated, - message: - """ + extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewState { + @available( + *, deprecated, + message: + """ Dynamic member lookup is no longer supported for bindable state. Instead of dot-chaining on \ the view store, e.g. 'viewStore.$value', invoke the 'binding' method on view store with a \ key path to the value, e.g. 'viewStore.binding(\\.$value)'. For more on this change, see: \ https://github.com/pointfreeco/swift-composable-architecture/pull/810 """ - ) - @MainActor - public subscript( - dynamicMember keyPath: WritableKeyPath> - ) -> Binding { - self.binding( - get: { $0[keyPath: keyPath].wrappedValue }, - send: { .binding(.set(keyPath, $0)) } ) + @MainActor + public subscript( + dynamicMember keyPath: WritableKeyPath> + ) -> Binding { + self.binding( + get: { $0[keyPath: keyPath].wrappedValue }, + send: { .binding(.set(keyPath, $0)) } + ) + } } -} -// MARK: - Deprecated after 0.25.0: + // MARK: - Deprecated after 0.25.0: -extension BindingAction { - @available( - *, deprecated, - message: - """ + extension BindingAction { + @available( + *, deprecated, + message: + """ For improved safety, bindable properties must now be wrapped explicitly in 'BindingState', \ and accessed via key paths to that 'BindingState', like '\\.$value' """ - ) - public static func set( - _ keyPath: WritableKeyPath, - _ value: Value - ) -> Self { - .init( - keyPath: keyPath, - set: { $0[keyPath: keyPath] = value }, - value: value, - valueIsEqualTo: { $0 as? Value == value } ) - } + public static func set( + _ keyPath: WritableKeyPath, + _ value: Value + ) -> Self { + .init( + keyPath: keyPath, + set: { $0[keyPath: keyPath] = value }, + value: value, + valueIsEqualTo: { $0 as? Value == value } + ) + } - @available( - *, deprecated, - message: - """ + @available( + *, deprecated, + message: + """ For improved safety, bindable properties must now be wrapped explicitly in 'BindingState', \ and accessed via key paths to that 'BindingState', like '\\.$value' """ - ) - public static func ~= ( - keyPath: WritableKeyPath, - bindingAction: Self - ) -> Bool { - keyPath == bindingAction.keyPath + ) + public static func ~= ( + keyPath: WritableKeyPath, + bindingAction: Self + ) -> Bool { + keyPath == bindingAction.keyPath + } } -} -extension AnyReducer { - @available( - *, deprecated, - message: - """ + extension AnyReducer { + @available( + *, deprecated, + message: + """ 'Reducer.binding()' no longer takes an explicit extract function and instead the reducer's \ 'Action' type must conform to 'BindableAction' """ - ) + ) public func binding(action toBindingAction: @escaping (Action) -> BindingAction?) -> Self { - Self { state, action, environment in - toBindingAction(action)?.set(&state) - return self.run(&state, action, environment) + Self { state, action, environment in + toBindingAction(action)?.set(&state) + return self.run(&state, action, environment) + } } } -} #if canImport(SwiftUI) -extension ViewStore { - @available( - *, deprecated, - message: - """ + extension ViewStore { + @available( + *, deprecated, + message: + """ For improved safety, bindable properties must now be wrapped explicitly in 'BindingState'. \ Bindings are now derived via 'ViewStore.binding' with a key path to that 'BindingState' \ (for example, 'viewStore.binding(\\.$value)'). For dynamic member lookup to be available, \ the view store's 'Action' type must also conform to 'BindableAction'. """ - ) - @MainActor - public func binding( - keyPath: WritableKeyPath, - send action: @escaping (BindingAction) -> ViewAction - ) -> Binding { - self.binding( - get: { $0[keyPath: keyPath] }, - send: { action(.set(keyPath, $0)) } - ) - } -} + ) + @MainActor + public func binding( + keyPath: WritableKeyPath, + send action: @escaping (BindingAction) -> ViewAction + ) -> Binding { + self.binding( + get: { $0[keyPath: keyPath] }, + send: { action(.set(keyPath, $0)) } + ) + } + } #endif -// MARK: - Deprecated after 0.20.0: + // MARK: - Deprecated after 0.20.0: -extension AnyReducer { - @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead.") - public func forEach( - state toElementsState: WritableKeyPath, - action toElementAction: CasePath, - environment toElementEnvironment: @escaping (ParentEnvironment) -> Environment, - breakpointOnNil: Bool = true, - file: StaticString = #file, - fileID: StaticString = #fileID, - line: UInt = #line - ) -> AnyReducer { - .init { parentState, parentAction, parentEnvironment in - guard let (index, action) = toElementAction.extract(from: parentAction) else { - return .none - } - if index >= parentState[keyPath: toElementsState].endIndex { - runtimeWarn( - """ - A "forEach" reducer at "\(fileID):\(line)" received an action when state contained no \ - element at that index. … - - Action: - \(debugCaseOutput(action)) - Index: - \(index) - - This is generally considered an application logic error, and can happen for a few \ - reasons: - - • This "forEach" reducer was combined with or run from another reducer that removed \ - the element at this index when it handled this action. To fix this make sure that this \ - "forEach" reducer is run before any other reducers that can move or remove elements \ - from state. This ensures that "forEach" reducers can handle their actions for the \ - element at the intended index. - - • An in-flight effect emitted this action while state contained no element at this \ - index. While it may be perfectly reasonable to ignore this action, you may want to \ - cancel the associated effect when moving or removing an element. If your "forEach" \ - reducer returns any long-living effects, you should use the identifier-based "forEach" \ - instead. - - • This action was sent to the store while its state contained no element at this index \ - To fix this make sure that actions for this reducer can only be sent to a view store \ - when its state contains an element at this index. In SwiftUI applications, use \ - "ForEachStore". - """, - file: file, - line: line + extension AnyReducer { + @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead.") + public func forEach( + state toElementsState: WritableKeyPath, + action toElementAction: CasePath, + environment toElementEnvironment: @escaping (ParentEnvironment) -> Environment, + breakpointOnNil: Bool = true, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) -> AnyReducer { + .init { parentState, parentAction, parentEnvironment in + guard let (index, action) = toElementAction.extract(from: parentAction) else { + return .none + } + if index >= parentState[keyPath: toElementsState].endIndex { + runtimeWarn( + """ + A "forEach" reducer at "\(fileID):\(line)" received an action when state contained no \ + element at that index. … + + Action: + \(debugCaseOutput(action)) + Index: + \(index) + + This is generally considered an application logic error, and can happen for a few \ + reasons: + + • This "forEach" reducer was combined with or run from another reducer that removed \ + the element at this index when it handled this action. To fix this make sure that this \ + "forEach" reducer is run before any other reducers that can move or remove elements \ + from state. This ensures that "forEach" reducers can handle their actions for the \ + element at the intended index. + + • An in-flight effect emitted this action while state contained no element at this \ + index. While it may be perfectly reasonable to ignore this action, you may want to \ + cancel the associated effect when moving or removing an element. If your "forEach" \ + reducer returns any long-living effects, you should use the identifier-based "forEach" \ + instead. + + • This action was sent to the store while its state contained no element at this index \ + To fix this make sure that actions for this reducer can only be sent to a view store \ + when its state contains an element at this index. In SwiftUI applications, use \ + "ForEachStore". + """, + file: file, + line: line + ) + return .none + } + return self.run( + &parentState[keyPath: toElementsState][index], + action, + toElementEnvironment(parentEnvironment) ) - return .none + .map { toElementAction.embed((index, $0)) } } - return self.run( - &parentState[keyPath: toElementsState][index], - action, - toElementEnvironment(parentEnvironment) - ) - .map { toElementAction.embed((index, $0)) } } } -} -extension ForEachStore { - @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead.") - public init( - _ store: Store, - id: KeyPath, - @ViewBuilder content: @escaping (Store) -> EachContent - ) - where - Data == [EachState], - Content == WithViewStore< - [ID], (Data.Index, EachAction), ForEach<[(offset: Int, element: ID)], ID, EachContent> - > - { + extension ForEachStore { + @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead.") + public init( + _ store: Store, + id: KeyPath, + @ViewBuilder content: @escaping (Store) -> EachContent + ) + where + Data == [EachState], + Content == WithViewStore< + [ID], (Data.Index, EachAction), ForEach<[(offset: Int, element: ID)], ID, EachContent> + > + { let data = store.state - self.data = data + self.data = data self.content = WithViewStore(store.scope(state: { $0.map { $0[keyPath: id] } })) { viewStore in - ForEach(Array(viewStore.state.enumerated()), id: \.element) { index, _ in - content( - store.scope( - state: { index < $0.endIndex ? $0[index] : data[index] }, - action: { (index, $0) } + ForEach(Array(viewStore.state.enumerated()), id: \.element) { index, _ in + content( + store.scope( + state: { index < $0.endIndex ? $0[index] : data[index] }, + action: { (index, $0) } + ) ) - ) + } } } - } - @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead.") - public init( - _ store: Store, - @ViewBuilder content: @escaping (Store) -> EachContent - ) - where - Data == [EachState], - Content == WithViewStore< - [ID], (Data.Index, EachAction), ForEach<[(offset: Int, element: ID)], ID, EachContent> - >, - EachState: Identifiable, - EachState.ID == ID - { - self.init(store, id: \.id, content: content) + @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead.") + public init( + _ store: Store, + @ViewBuilder content: @escaping (Store) -> EachContent + ) + where + Data == [EachState], + Content == WithViewStore< + [ID], (Data.Index, EachAction), ForEach<[(offset: Int, element: ID)], ID, EachContent> + >, + EachState: Identifiable, + EachState.ID == ID + { + self.init(store, id: \.id, content: content) + } } -} #endif diff --git a/Sources/ComposableArchitecture/SwiftUI/Alert.swift b/Sources/ComposableArchitecture/SwiftUI/Alert.swift index 6d785d065..22d0dfdf1 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Alert.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Alert.swift @@ -1,74 +1,74 @@ #if canImport(SwiftUI) -import SwiftUI + import SwiftUI -extension View { - /// Displays an alert when then store's state becomes non-`nil`, and dismisses it when it becomes - /// `nil`. - /// - /// - Parameters: - /// - store: A store that describes if the alert is shown or dismissed. - /// - dismissal: An action to send when the alert is dismissed through non-user actions, such - /// as when an alert is automatically dismissed by the system. Use this action to `nil` out - /// the associated alert state. - @ViewBuilder public func alert( - _ store: Store?, Action>, - dismiss: Action - ) -> some View { - if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { - self.modifier( - NewAlertModifier( - viewStore: ViewStore(store, removeDuplicates: { $0?.id == $1?.id }), - dismiss: dismiss + extension View { + /// Displays an alert when then store's state becomes non-`nil`, and dismisses it when it becomes + /// `nil`. + /// + /// - Parameters: + /// - store: A store that describes if the alert is shown or dismissed. + /// - dismissal: An action to send when the alert is dismissed through non-user actions, such + /// as when an alert is automatically dismissed by the system. Use this action to `nil` out + /// the associated alert state. + @ViewBuilder public func alert( + _ store: Store?, Action>, + dismiss: Action + ) -> some View { + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + self.modifier( + NewAlertModifier( + viewStore: ViewStore(store, removeDuplicates: { $0?.id == $1?.id }), + dismiss: dismiss + ) ) - ) - } else { - self.modifier( - OldAlertModifier( - viewStore: ViewStore(store, removeDuplicates: { $0?.id == $1?.id }), - dismiss: dismiss + } else { + self.modifier( + OldAlertModifier( + viewStore: ViewStore(store, removeDuplicates: { $0?.id == $1?.id }), + dismiss: dismiss + ) ) - ) + } } } -} -// NB: Workaround for iOS 14 runtime crashes during iOS 15 availability checks. -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -private struct NewAlertModifier: ViewModifier { - @StateObject var viewStore: ViewStore?, Action> - let dismiss: Action + // NB: Workaround for iOS 14 runtime crashes during iOS 15 availability checks. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + private struct NewAlertModifier: ViewModifier { + @StateObject var viewStore: ViewStore?, Action> + let dismiss: Action - func body(content: Content) -> some View { - content.alert( - (viewStore.state?.title).map { Text($0) } ?? Text(""), - isPresented: viewStore.binding(send: dismiss).isPresent(), - presenting: viewStore.state, - actions: { - ForEach($0.buttons) { - Button($0) { action in - if let action = action { - viewStore.send(action) + func body(content: Content) -> some View { + content.alert( + (viewStore.state?.title).map { Text($0) } ?? Text(""), + isPresented: viewStore.binding(send: dismiss).isPresent(), + presenting: viewStore.state, + actions: { + ForEach($0.buttons) { + Button($0) { action in + if let action = action { + viewStore.send(action) + } } } - } - }, - message: { $0.message.map { Text($0) } } - ) + }, + message: { $0.message.map { Text($0) } } + ) + } } -} -private struct OldAlertModifier: ViewModifier { - @ObservedObject var viewStore: ViewStore?, Action> - let dismiss: Action + private struct OldAlertModifier: ViewModifier { + @ObservedObject var viewStore: ViewStore?, Action> + let dismiss: Action - func body(content: Content) -> some View { - content.alert(item: viewStore.binding(send: dismiss)) { state in - Alert(state) { action in - if let action = action { - viewStore.send(action) + func body(content: Content) -> some View { + content.alert(item: viewStore.binding(send: dismiss)) { state in + Alert(state) { action in + if let action = action { + viewStore.send(action) + } } } } } -} #endif diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index f932991d5..78b1e63b3 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -1,7 +1,7 @@ import CustomDump #if canImport(SwiftUI) -import SwiftUI + import SwiftUI #endif /// A property wrapper type that can designate properties of app state that can be directly bindable @@ -127,37 +127,37 @@ extension BindableAction { } #if canImport(SwiftUI) -extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewState { - /// Returns a binding to the resulting bindable state of a given key path. - /// - /// - Parameter keyPath: A key path to a specific bindable state. - /// - Returns: A new binding. - public func binding( - _ keyPath: WritableKeyPath>, - file: StaticString = #file, - fileID: StaticString = #fileID, - line: UInt = #line - ) -> Binding { - self.binding( - get: { $0[keyPath: keyPath].wrappedValue }, - send: { value in - #if DEBUG - let debugger = BindableActionViewStoreDebugger( - value: value, bindableActionType: ViewAction.self, file: file, fileID: fileID, - line: line - ) - let set: (inout ViewState) -> Void = { - $0[keyPath: keyPath].wrappedValue = value - debugger.wasCalled = true - } - #else - let set: (inout ViewState) -> Void = { $0[keyPath: keyPath].wrappedValue = value } - #endif - return .binding(.init(keyPath: keyPath, set: set, value: value)) - } - ) + extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewState { + /// Returns a binding to the resulting bindable state of a given key path. + /// + /// - Parameter keyPath: A key path to a specific bindable state. + /// - Returns: A new binding. + public func binding( + _ keyPath: WritableKeyPath>, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) -> Binding { + self.binding( + get: { $0[keyPath: keyPath].wrappedValue }, + send: { value in + #if DEBUG + let debugger = BindableActionViewStoreDebugger( + value: value, bindableActionType: ViewAction.self, file: file, fileID: fileID, + line: line + ) + let set: (inout ViewState) -> Void = { + $0[keyPath: keyPath].wrappedValue = value + debugger.wasCalled = true + } + #else + let set: (inout ViewState) -> Void = { $0[keyPath: keyPath].wrappedValue = value } + #endif + return .binding(.init(keyPath: keyPath, set: set, value: value)) + } + ) + } } -} #endif /// An action that describes simple mutations to some root state at a writable key path. diff --git a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift index b06a5559a..b1c73b67b 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift @@ -1,90 +1,90 @@ #if canImport(SwiftUI) -import SwiftUI + import SwiftUI @available(iOS 13, macOS 10.15, macCatalyst 13, tvOS 13, watchOS 6, *) -extension View { - /// Displays a dialog when the store's state becomes non-`nil`, and dismisses it when it becomes - /// `nil`. - /// - /// - Parameters: - /// - store: A store that describes if the dialog is shown or dismissed. - /// - dismissal: An action to send when the dialog is dismissed through non-user actions, such - /// as when a dialog is automatically dismissed by the system. Use this action to `nil` out - /// the associated dialog state. - @available(iOS 13, *) - @available(macOS 12, *) - @available(tvOS 13, *) - @available(watchOS 6, *) - @ViewBuilder public func confirmationDialog( - _ store: Store?, Action>, - dismiss: Action - ) -> some View { - if #available(iOS 15, tvOS 15, watchOS 8, *) { - self.modifier( - NewConfirmationDialogModifier( - viewStore: ViewStore(store, removeDuplicates: { $0?.id == $1?.id }), - dismiss: dismiss - ) - ) - } else { - #if !os(macOS) + extension View { + /// Displays a dialog when the store's state becomes non-`nil`, and dismisses it when it becomes + /// `nil`. + /// + /// - Parameters: + /// - store: A store that describes if the dialog is shown or dismissed. + /// - dismissal: An action to send when the dialog is dismissed through non-user actions, such + /// as when a dialog is automatically dismissed by the system. Use this action to `nil` out + /// the associated dialog state. + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + @ViewBuilder public func confirmationDialog( + _ store: Store?, Action>, + dismiss: Action + ) -> some View { + if #available(iOS 15, tvOS 15, watchOS 8, *) { self.modifier( - OldConfirmationDialogModifier( + NewConfirmationDialogModifier( viewStore: ViewStore(store, removeDuplicates: { $0?.id == $1?.id }), dismiss: dismiss ) ) - #endif + } else { + #if !os(macOS) + self.modifier( + OldConfirmationDialogModifier( + viewStore: ViewStore(store, removeDuplicates: { $0?.id == $1?.id }), + dismiss: dismiss + ) + ) + #endif + } } } -} -// NB: Workaround for iOS 14 runtime crashes during iOS 15 availability checks. -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -private struct NewConfirmationDialogModifier: ViewModifier { - @StateObject var viewStore: ViewStore?, Action> - let dismiss: Action + // NB: Workaround for iOS 14 runtime crashes during iOS 15 availability checks. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + private struct NewConfirmationDialogModifier: ViewModifier { + @StateObject var viewStore: ViewStore?, Action> + let dismiss: Action - func body(content: Content) -> some View { - content.confirmationDialog( - (viewStore.state?.title).map { Text($0) } ?? Text(""), - isPresented: viewStore.binding(send: dismiss).isPresent(), - titleVisibility: viewStore.state.map { .init($0.titleVisibility) } ?? .automatic, - presenting: viewStore.state, - actions: { - ForEach($0.buttons) { - Button($0) { action in - if let action = action { - viewStore.send(action) + func body(content: Content) -> some View { + content.confirmationDialog( + (viewStore.state?.title).map { Text($0) } ?? Text(""), + isPresented: viewStore.binding(send: dismiss).isPresent(), + titleVisibility: viewStore.state.map { .init($0.titleVisibility) } ?? .automatic, + presenting: viewStore.state, + actions: { + ForEach($0.buttons) { + Button($0) { action in + if let action = action { + viewStore.send(action) + } } } - } - }, - message: { $0.message.map { Text($0) } } - ) + }, + message: { $0.message.map { Text($0) } } + ) + } } -} -@available(iOS 13, *) -@available(macOS 12, *) -@available(tvOS 13, *) -@available(watchOS 6, *) -private struct OldConfirmationDialogModifier: ViewModifier { - @ObservedObject var viewStore: ViewStore?, Action> - let dismiss: Action + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + private struct OldConfirmationDialogModifier: ViewModifier { + @ObservedObject var viewStore: ViewStore?, Action> + let dismiss: Action - func body(content: Content) -> some View { - #if !os(macOS) - return content.actionSheet(item: viewStore.binding(send: dismiss)) { - ActionSheet($0) { action in - if let action = action { - viewStore.send(action) + func body(content: Content) -> some View { + #if !os(macOS) + return content.actionSheet(item: viewStore.binding(send: dismiss)) { + ActionSheet($0) { action in + if let action = action { + viewStore.send(action) + } } } - } - #else - return EmptyView() - #endif + #else + return EmptyView() + #endif + } } -} #endif diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index a8712af9c..50fd6c79b 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,302 @@ 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/BindingTests.swift b/Tests/ComposableArchitectureTests/BindingTests.swift index dcd225de9..f1a290603 100644 --- a/Tests/ComposableArchitectureTests/BindingTests.swift +++ b/Tests/ComposableArchitectureTests/BindingTests.swift @@ -1,46 +1,46 @@ #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 + } #endif diff --git a/Tests/ComposableArchitectureTests/CompatibilityTests.swift b/Tests/ComposableArchitectureTests/CompatibilityTests.swift index 3fc25dd93..fc3ec83dc 100644 --- a/Tests/ComposableArchitectureTests/CompatibilityTests.swift +++ b/Tests/ComposableArchitectureTests/CompatibilityTests.swift @@ -4,120 +4,120 @@ import XCTest // `@MainActor` introduces issues gathering tests on Linux #if !os(Linux) -@MainActor -final class CompatibilityTests: XCTestCase { - // Actions can be re-entrantly sent into the store if an action is sent that holds an object - // which sends an action on deinit. In order to prevent a simultaneous access exception for this - // case we need to use `withExtendedLifetime` on the buffered actions when clearing them out. - func testCaseStudy_ActionReentranceFromClearedBufferCausingDeinitAction() { - let cancelID = UUID() - - struct State: Equatable {} - enum Action: Equatable { - case start - case kickOffAction - case actionSender(OnDeinit) - case stop - - var description: String { - switch self { - case .start: - return "start" - case .kickOffAction: - return "kickOffAction" - case .actionSender: - return "actionSender" - case .stop: - return "stop" + @MainActor + final class CompatibilityTests: XCTestCase { + // Actions can be re-entrantly sent into the store if an action is sent that holds an object + // which sends an action on deinit. In order to prevent a simultaneous access exception for this + // case we need to use `withExtendedLifetime` on the buffered actions when clearing them out. + func testCaseStudy_ActionReentranceFromClearedBufferCausingDeinitAction() { + let cancelID = UUID() + + struct State: Equatable {} + enum Action: Equatable { + case start + case kickOffAction + case actionSender(OnDeinit) + case stop + + var description: String { + switch self { + case .start: + return "start" + case .kickOffAction: + return "kickOffAction" + case .actionSender: + return "actionSender" + case .stop: + return "stop" + } } } - } let (signal, observer) = Signal.pipe() - var handledActions: [String] = [] + var handledActions: [String] = [] - let reducer = Reduce { state, action in - handledActions.append(action.description) + let reducer = Reduce { state, action in + handledActions.append(action.description) - switch action { - case .start: + switch action { + case .start: return signal.producer - .eraseToEffect() - .cancellable(id: cancelID) + .eraseToEffect() + .cancellable(id: cancelID) - case .kickOffAction: - return .send(.actionSender(OnDeinit { observer.send(value: .stop) })) + case .kickOffAction: + return .send(.actionSender(OnDeinit { observer.send(value: .stop) })) - case .actionSender: - return .none + case .actionSender: + return .none - case .stop: - return .cancel(id: cancelID) + case .stop: + return .cancel(id: cancelID) + } } - } - let store = Store( - initialState: .init(), - reducer: reducer - ) - - let viewStore = ViewStore(store, observe: { $0 }) - - viewStore.send(.start) - viewStore.send(.kickOffAction) - - XCTAssertEqual( - handledActions, - [ - "start", - "kickOffAction", - "actionSender", - "stop", - ] - ) - } + let store = Store( + initialState: .init(), + reducer: reducer + ) + + let viewStore = ViewStore(store, observe: { $0 }) + + viewStore.send(.start) + viewStore.send(.kickOffAction) + + XCTAssertEqual( + handledActions, + [ + "start", + "kickOffAction", + "actionSender", + "stop", + ] + ) + } - // Actions can be re-entrantly sent into the store while observing changes to the store's state. - // In such cases we need to take special care that those re-entrant actions are handled _after_ - // the original action. - // - // In particular, this means that in the implementation of `Store.send` we need to flip - // `isSending` to false _after_ the store's state mutation is made so that re-entrant actions - // are buffered rather than immediately handled. - func testCaseStudy_ActionReentranceFromStateObservation() { - let store = Store( - initialState: 0, - reducer: Reduce { state, action in - state = action - return .none - } - ) + // Actions can be re-entrantly sent into the store while observing changes to the store's state. + // In such cases we need to take special care that those re-entrant actions are handled _after_ + // the original action. + // + // In particular, this means that in the implementation of `Store.send` we need to flip + // `isSending` to false _after_ the store's state mutation is made so that re-entrant actions + // are buffered rather than immediately handled. + func testCaseStudy_ActionReentranceFromStateObservation() { + let store = Store( + initialState: 0, + reducer: Reduce { state, action in + state = action + return .none + } + ) - let viewStore = ViewStore(store, observe: { $0 }) + let viewStore = ViewStore(store, observe: { $0 }) viewStore.produced.producer .startWithValues { value in - if value == 1 { - viewStore.send(0) + if value == 1 { + viewStore.send(0) + } } - } - var stateChanges: [Int] = [] + var stateChanges: [Int] = [] viewStore.produced.producer .startWithValues { stateChanges.append($0) } - XCTAssertEqual(stateChanges, [0]) - viewStore.send(1) - XCTAssertEqual(stateChanges, [0, 1, 0]) + XCTAssertEqual(stateChanges, [0]) + viewStore.send(1) + XCTAssertEqual(stateChanges, [0, 1, 0]) + } } -} -private final class OnDeinit: Equatable { - private let onDeinit: () -> Void - init(onDeinit: @escaping () -> Void) { - self.onDeinit = onDeinit + private final class OnDeinit: Equatable { + private let onDeinit: () -> Void + init(onDeinit: @escaping () -> Void) { + self.onDeinit = onDeinit + } + deinit { self.onDeinit() } + static func == (lhs: OnDeinit, rhs: OnDeinit) -> Bool { true } } - deinit { self.onDeinit() } - static func == (lhs: OnDeinit, rhs: OnDeinit) -> Bool { true } -} #endif diff --git a/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift b/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift index 43ef3c970..03f0e419e 100644 --- a/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift +++ b/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift @@ -4,150 +4,150 @@ import XCTest // `@MainActor` introduces issues gathering tests on Linux #if !os(Linux) -@MainActor -final class ComposableArchitectureTests: XCTestCase { - func testScheduling() async { - struct Counter: ReducerProtocol { - typealias State = Int - enum Action: Equatable { - case incrAndSquareLater - case incrNow - case squareNow - } + @MainActor + final class ComposableArchitectureTests: XCTestCase { + func testScheduling() async { + struct Counter: ReducerProtocol { + typealias State = Int + enum Action: Equatable { + case incrAndSquareLater + case incrNow + case squareNow + } @Dependency(\.mainQueueScheduler) var mainQueue - func reduce(into state: inout State, action: Action) -> EffectTask { - switch action { - case .incrAndSquareLater: - return .merge( - EffectTask(value: .incrNow) + func reduce(into state: inout State, action: Action) -> EffectTask { + switch action { + case .incrAndSquareLater: + return .merge( + EffectTask(value: .incrNow) .deferred(for: 2, scheduler: self.mainQueue), - EffectTask(value: .squareNow) + EffectTask(value: .squareNow) .deferred(for: 1, scheduler: self.mainQueue), - EffectTask(value: .squareNow) + EffectTask(value: .squareNow) .deferred(for: 2, scheduler: self.mainQueue) - ) - case .incrNow: - state += 1 - return .none - case .squareNow: - state *= state - return .none + ) + case .incrNow: + state += 1 + return .none + case .squareNow: + state *= state + return .none + } } } - } - let mainQueue = TestScheduler() + let mainQueue = TestScheduler() - let store = TestStore( - initialState: 2, - reducer: Counter() - ) { - $0.mainQueue = mainQueue - } + let store = TestStore( + initialState: 2, + reducer: Counter() + ) { + $0.mainQueueScheduler = mainQueue + } - await store.send(.incrAndSquareLater) - await mainQueue.advance(by: 1) - await store.receive(.squareNow) { $0 = 4 } - await mainQueue.advance(by: 1) - await store.receive(.incrNow) { $0 = 5 } - await store.receive(.squareNow) { $0 = 25 } - - await store.send(.incrAndSquareLater) - await mainQueue.advance(by: 2) - await store.receive(.squareNow) { $0 = 625 } - await store.receive(.incrNow) { $0 = 626 } - await store.receive(.squareNow) { $0 = 391876 } - } + await store.send(.incrAndSquareLater) + await mainQueue.advance(by: 1) + await store.receive(.squareNow) { $0 = 4 } + await mainQueue.advance(by: 1) + await store.receive(.incrNow) { $0 = 5 } + await store.receive(.squareNow) { $0 = 25 } + + await store.send(.incrAndSquareLater) + await mainQueue.advance(by: 2) + await store.receive(.squareNow) { $0 = 625 } + await store.receive(.incrNow) { $0 = 626 } + await store.receive(.squareNow) { $0 = 391876 } + } - func testSimultaneousWorkOrdering() { + func testSimultaneousWorkOrdering() { let mainQueue = TestScheduler() - var values: [Int] = [] + var values: [Int] = [] mainQueue.schedule(after: .seconds(0), interval: .seconds(1)) { values.append(1) } mainQueue.schedule(after: .seconds(0), interval: .seconds(2)) { values.append(42) } - XCTAssertEqual(values, []) - mainQueue.advance() - XCTAssertEqual(values, [1, 42]) - mainQueue.advance(by: 2) + XCTAssertEqual(values, []) + mainQueue.advance() + XCTAssertEqual(values, [1, 42]) + mainQueue.advance(by: 2) XCTAssertEqual(values, [1, 42, 1, 42, 1]) - } + } - func testLongLivingEffects() async { - enum Action { case end, incr, start } + func testLongLivingEffects() async { + enum Action { case end, incr, start } - let effect = AsyncStream.streamWithContinuation() + let effect = AsyncStream.streamWithContinuation() - let reducer = Reduce { state, action in - switch action { - case .end: - return .fireAndForget { - effect.continuation.finish() - } - case .incr: - state += 1 - return .none - case .start: - return .run { send in - for await _ in effect.stream { - await send(.incr) + let reducer = Reduce { state, action in + switch action { + case .end: + return .fireAndForget { + effect.continuation.finish() + } + case .incr: + state += 1 + return .none + case .start: + return .run { send in + for await _ in effect.stream { + await send(.incr) + } } } } - } - let store = TestStore(initialState: 0, reducer: reducer) + let store = TestStore(initialState: 0, reducer: reducer) - await store.send(.start) - await store.send(.incr) { $0 = 1 } - effect.continuation.yield() - await store.receive(.incr) { $0 = 2 } - await store.send(.end) - } + await store.send(.start) + await store.send(.incr) { $0 = 1 } + effect.continuation.yield() + await store.receive(.incr) { $0 = 2 } + await store.send(.end) + } - func testCancellation() async { + func testCancellation() async { let mainQueue = TestScheduler() - enum Action: Equatable { - case cancel - case incr - case response(Int) - } + enum Action: Equatable { + case cancel + case incr + case response(Int) + } - let reducer = Reduce { state, action in - enum CancelID {} + let reducer = Reduce { state, action in + enum CancelID {} - switch action { - case .cancel: - return .cancel(id: CancelID.self) + switch action { + case .cancel: + return .cancel(id: CancelID.self) - case .incr: - state += 1 - return .task { [state] in - try await mainQueue.sleep(for: .seconds(1)) - return .response(state * state) - } - .cancellable(id: CancelID.self) + case .incr: + state += 1 + return .task { [state] in + try await mainQueue.sleep(for: .seconds(1)) + return .response(state * state) + } + .cancellable(id: CancelID.self) - case let .response(value): - state = value - return .none + case let .response(value): + state = value + return .none + } } - } - let store = TestStore( - initialState: 0, - reducer: reducer - ) + let store = TestStore( + initialState: 0, + reducer: reducer + ) - await store.send(.incr) { $0 = 1 } - await mainQueue.advance(by: .seconds(1)) - await store.receive(.response(1)) + await store.send(.incr) { $0 = 1 } + await mainQueue.advance(by: .seconds(1)) + await store.receive(.response(1)) - await store.send(.incr) { $0 = 2 } - await store.send(.cancel) + await store.send(.incr) { $0 = 2 } + await store.send(.cancel) // NB: Wait a bit more time to handle effects so this test is less brittle in CI await store.finish(timeout: NSEC_PER_SEC) + } } -} #endif diff --git a/Tests/ComposableArchitectureTests/DebugTests.swift b/Tests/ComposableArchitectureTests/DebugTests.swift index fb258936a..f920546d0 100644 --- a/Tests/ComposableArchitectureTests/DebugTests.swift +++ b/Tests/ComposableArchitectureTests/DebugTests.swift @@ -44,23 +44,23 @@ } #if canImport(SwiftUI) - func testBindingAction() { - struct State { - @BindingState var width = 0 - } - let action = BindingAction.set(\State.$width, 50) - var dump = "" - customDump(action, to: &dump) - XCTAssertEqual( - dump, - #""" - BindingAction.set( - WritableKeyPath>, - 50 + func testBindingAction() { + struct State { + @BindingState var width = 0 + } + let action = BindingAction.set(\State.$width, 50) + var dump = "" + customDump(action, to: &dump) + XCTAssertEqual( + dump, + #""" + BindingAction.set( + WritableKeyPath>, + 50 + ) + """# ) - """# - ) - } + } #endif @MainActor diff --git a/Tests/ComposableArchitectureTests/EffectTests.swift b/Tests/ComposableArchitectureTests/EffectTests.swift index ed14fe646..d733501f5 100644 --- a/Tests/ComposableArchitectureTests/EffectTests.swift +++ b/Tests/ComposableArchitectureTests/EffectTests.swift @@ -4,12 +4,12 @@ import XCTest // `@MainActor` introduces issues gathering tests on Linux #if !os(Linux) -@MainActor -final class EffectTests: XCTestCase { + @MainActor + final class EffectTests: XCTestCase { let mainQueue = TestScheduler() func testEraseToEffectWithError() { - struct Error: Swift.Error, Equatable {} + struct Error: Swift.Error, Equatable {} SignalProducer(result: .success(42)) .startWithResult { XCTAssertNoDifference($0, .success(42)) } @@ -21,116 +21,116 @@ final class EffectTests: XCTestCase { .startWithResult { XCTAssertNoDifference($0, .success(42)) } SignalProducer(result: .success(42)) - .catchToEffect { - switch $0 { - case let .success(val): - return val - case .failure: - return -1 + .catchToEffect { + switch $0 { + case let .success(val): + return val + case .failure: + return -1 + } } - } .producer .startWithValues { XCTAssertNoDifference($0, 42) } SignalProducer(result: .failure(Error())) - .catchToEffect { - switch $0 { - case let .success(val): - return val - case .failure: - return -1 + .catchToEffect { + switch $0 { + case let .success(val): + return val + case .failure: + return -1 + } } - } .producer .startWithValues { XCTAssertNoDifference($0, -1) } - } + } - #if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) - func testConcatenate() async { - await _withMainSerialExecutor { - if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { - let clock = TestClock() - var values: [Int] = [] + #if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) + func testConcatenate() async { + await _withMainSerialExecutor { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + let clock = TestClock() + var values: [Int] = [] let effect = EffectProducer.concatenate( - (1...3).map { count in - .task { - try await clock.sleep(for: .seconds(count)) - return count + (1...3).map { count in + .task { + try await clock.sleep(for: .seconds(count)) + return count + } } - } - ) + ) effect.producer.startWithValues { values.append($0) } - XCTAssertEqual(values, []) + XCTAssertEqual(values, []) - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values, [1]) + await clock.advance(by: .seconds(1)) + XCTAssertEqual(values, [1]) - await clock.advance(by: .seconds(2)) - XCTAssertEqual(values, [1, 2]) + await clock.advance(by: .seconds(2)) + XCTAssertEqual(values, [1, 2]) - await clock.advance(by: .seconds(3)) - XCTAssertEqual(values, [1, 2, 3]) + await clock.advance(by: .seconds(3)) + XCTAssertEqual(values, [1, 2, 3]) - await clock.run() - XCTAssertEqual(values, [1, 2, 3]) + await clock.run() + XCTAssertEqual(values, [1, 2, 3]) + } } } - } - #endif + #endif - func testConcatenateOneEffect() { - var values: [Int] = [] + func testConcatenateOneEffect() { + var values: [Int] = [] - let effect = EffectTask.concatenate( + let effect = EffectTask.concatenate( EffectTask(value: 1).deferred(for: 1, scheduler: mainQueue) - ) + ) effect.producer.startWithValues { values.append($0) } - XCTAssertEqual(values, []) + XCTAssertEqual(values, []) - self.mainQueue.advance(by: 1) - XCTAssertEqual(values, [1]) + self.mainQueue.advance(by: 1) + XCTAssertEqual(values, [1]) - self.mainQueue.run() - XCTAssertEqual(values, [1]) - } - - #if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) - func testMerge() async { - if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { - await _withMainSerialExecutor { - let clock = TestClock() + self.mainQueue.run() + XCTAssertEqual(values, [1]) + } - let effect = EffectProducer.merge( - (1...3).map { count in - .task { - try await clock.sleep(for: .seconds(count)) - return count - } - } - ) + #if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) + func testMerge() async { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + await _withMainSerialExecutor { + let clock = TestClock() + + let effect = EffectProducer.merge( + (1...3).map { count in + .task { + try await clock.sleep(for: .seconds(count)) + return count + } + } + ) - var values: [Int] = [] - effect.producer.startWithValues { values.append($0) } + var values: [Int] = [] + effect.producer.startWithValues { values.append($0) } - XCTAssertEqual(values, []) + XCTAssertEqual(values, []) - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values, [1]) + await clock.advance(by: .seconds(1)) + XCTAssertEqual(values, [1]) - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values, [1, 2]) + await clock.advance(by: .seconds(1)) + XCTAssertEqual(values, [1, 2]) - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values, [1, 2, 3]) + await clock.advance(by: .seconds(1)) + XCTAssertEqual(values, [1, 2, 3]) + } + } } - } - } - #endif + #endif func testEffectRunInitializer() { let effect = EffectTask.run { observer in @@ -138,160 +138,128 @@ final class EffectTests: XCTestCase { observer.send(value: 2) self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(1)) { observer.send(value: 3) - } + } self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(2)) { observer.send(value: 4) observer.sendCompleted() - } + } return AnyDisposable() - } + } - var values: [Int] = [] - var isComplete = false - effect + var values: [Int] = [] + var isComplete = false + effect .producer .on(completed: { isComplete = true }, value: { values.append($0) }) .start() - XCTAssertEqual(values, [1, 2]) - XCTAssertEqual(isComplete, false) + XCTAssertEqual(values, [1, 2]) + XCTAssertEqual(isComplete, false) - self.mainQueue.advance(by: 1) + self.mainQueue.advance(by: 1) - XCTAssertEqual(values, [1, 2, 3]) - XCTAssertEqual(isComplete, false) + XCTAssertEqual(values, [1, 2, 3]) + XCTAssertEqual(isComplete, false) - self.mainQueue.advance(by: 1) + self.mainQueue.advance(by: 1) - XCTAssertEqual(values, [1, 2, 3, 4]) - XCTAssertEqual(isComplete, true) - } + XCTAssertEqual(values, [1, 2, 3, 4]) + XCTAssertEqual(isComplete, true) + } func testEffectRunInitializer_WithCancellation() { - enum CancelID {} + enum CancelID {} - let effect = EffectTask.run { subscriber in + let effect = EffectTask.run { subscriber in subscriber.send(value: 1) self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(1)) { subscriber.send(value: 2) - } + } return AnyDisposable() - } - .cancellable(id: CancelID.self) + } + .cancellable(id: CancelID.self) - var values: [Int] = [] - var isComplete = false - effect + var values: [Int] = [] + var isComplete = false + effect .producer .on(completed: { isComplete = true }) .startWithValues { values.append($0) } - XCTAssertEqual(values, [1]) - XCTAssertEqual(isComplete, false) + XCTAssertEqual(values, [1]) + XCTAssertEqual(isComplete, false) - EffectTask.cancel(id: CancelID.self) + EffectTask.cancel(id: CancelID.self) .producer .startWithValues { _ in } - self.mainQueue.advance(by: 1) + self.mainQueue.advance(by: 1) - XCTAssertEqual(values, [1]) - XCTAssertEqual(isComplete, true) - } + XCTAssertEqual(values, [1]) + XCTAssertEqual(isComplete, true) + } - func testDoubleCancelInFlight() { - var result: Int? + func testDoubleCancelInFlight() { + var result: Int? _ = Effect(value: 42) - .cancellable(id: "id", cancelInFlight: true) - .cancellable(id: "id", cancelInFlight: true) + .cancellable(id: "id", cancelInFlight: true) + .cancellable(id: "id", cancelInFlight: true) .producer .startWithValues { result = $0 } - XCTAssertEqual(result, 42) - } + XCTAssertEqual(result, 42) + } #if DEBUG && !os(Linux) - func testUnimplemented() { + func testUnimplemented() { let effect = EffectTask.failing("unimplemented") _ = XCTExpectFailure { - effect + effect .producer .start() - } issueMatcher: { issue in - issue.compactDescription == "unimplemented - An unimplemented effect ran." + } issueMatcher: { issue in + issue.compactDescription == "unimplemented - An unimplemented effect ran." + } } - } - #endif + #endif #if canImport(_Concurrency) && compiler(>=5.5.2) - func testTask() async { - guard #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) else { return } - let effect = EffectTask.task { 42 } + func testTask() async { + guard #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) else { return } + let effect = EffectTask.task { 42 } for await result in effect.producer.values { - XCTAssertEqual(result, 42) - } - } - - func testCancellingTask_Infallible() { - @Sendable func work() async -> Int { - do { - try await Task.sleep(nanoseconds: NSEC_PER_MSEC) - XCTFail() - } catch { + XCTAssertEqual(result, 42) + } } - return 42 - } + + func testCancellingTask_Infallible() { + @Sendable func work() async -> Int { + do { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC) + XCTFail() + } catch { + } + return 42 + } let disposable = EffectTask.task { await work() } .producer .on( completed: { XCTFail() }, value: { _ in XCTFail() } - ) + ) .start(on: QueueScheduler.main) .start() disposable.dispose() - _ = XCTWaiter.wait(for: [.init()], timeout: 1.1) - } - #endif - - func testDependenciesTransferredToEffects_Task() async { - struct Feature: ReducerProtocol { - enum Action: Equatable { - case tap - case response(Int) - } - @Dependency(\.date) var date - func reduce(into state: inout Int, action: Action) -> EffectTask { - switch action { - case .tap: - return .task { - .response(Int(self.date.now.timeIntervalSinceReferenceDate)) - } - case let .response(value): - state = value - return .none - } + _ = XCTWaiter.wait(for: [.init()], timeout: 1.1) } - } - let store = TestStore( - initialState: 0, - reducer: Feature() - .dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890))) - ) - - await store.send(.tap).finish(timeout: NSEC_PER_SEC) - await store.receive(.response(1_234_567_890)) { - $0 = 1_234_567_890 - } - } + #endif - func testDependenciesTransferredToEffects_Run() async { - await _withMainSerialExecutor { + func testDependenciesTransferredToEffects_Task() async { struct Feature: ReducerProtocol { enum Action: Equatable { case tap @@ -301,8 +269,8 @@ final class EffectTests: XCTestCase { func reduce(into state: inout Int, action: Action) -> EffectTask { switch action { case .tap: - return .run { send in - await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) + return .task { + .response(Int(self.date.now.timeIntervalSinceReferenceDate)) } case let .response(value): state = value @@ -321,43 +289,75 @@ final class EffectTests: XCTestCase { $0 = 1_234_567_890 } } - } - func testMap() async { - @Dependency(\.date) var date - let effect = withDependencies { - $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) - } operation: { - EffectTask(value: ()).map { date() } + func testDependenciesTransferredToEffects_Run() async { + await _withMainSerialExecutor { + struct Feature: ReducerProtocol { + enum Action: Equatable { + case tap + case response(Int) + } + @Dependency(\.date) var date + func reduce(into state: inout Int, action: Action) -> EffectTask { + switch action { + case .tap: + return .run { send in + await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) + } + case let .response(value): + state = value + return .none + } + } + } + let store = TestStore( + initialState: 0, + reducer: Feature() + .dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890))) + ) + + await store.send(.tap).finish(timeout: NSEC_PER_SEC) + await store.receive(.response(1_234_567_890)) { + $0 = 1_234_567_890 + } + } } - var output: Date? - effect - .producer - .startWithValues { output = $0 } - XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) - if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + func testMap() async { + @Dependency(\.date) var date let effect = withDependencies { $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) } operation: { - EffectTask.task {}.map { date() } + EffectTask(value: ()).map { date() } } - output = await effect.values.first(where: { _ in true }) + var output: Date? + effect + .producer + .startWithValues { output = $0 } XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) + + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + let effect = withDependencies { + $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) + } operation: { + EffectTask.task {}.map { date() } + } + output = await effect.values.first(where: { _ in true }) + XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) + } } - } - func testCanary1() async { - for _ in 1...100 { - let task = TestStoreTask(rawValue: Task {}, timeout: NSEC_PER_SEC) - await task.finish() + func testCanary1() async { + for _ in 1...100 { + let task = TestStoreTask(rawValue: Task {}, timeout: NSEC_PER_SEC) + await task.finish() + } } - } - func testCanary2() async { - for _ in 1...100 { - let task = TestStoreTask(rawValue: nil, timeout: NSEC_PER_SEC) - await task.finish() + func testCanary2() async { + for _ in 1...100 { + let task = TestStoreTask(rawValue: nil, timeout: NSEC_PER_SEC) + await task.finish() + } } } -} #endif diff --git a/Tests/ComposableArchitectureTests/ReducerTests.swift b/Tests/ComposableArchitectureTests/ReducerTests.swift index 7ff82ec81..9ab20a95a 100644 --- a/Tests/ComposableArchitectureTests/ReducerTests.swift +++ b/Tests/ComposableArchitectureTests/ReducerTests.swift @@ -5,141 +5,141 @@ import XCTest // `@MainActor` introduces issues gathering tests on Linux #if !os(Linux) -@MainActor -final class ReducerTests: XCTestCase { - func testCallableAsFunction() { - let reducer = Reduce { state, _ in - state += 1 - return .none - } + @MainActor + final class ReducerTests: XCTestCase { + func testCallableAsFunction() { + let reducer = Reduce { state, _ in + state += 1 + return .none + } - var state = 0 - _ = reducer.reduce(into: &state, action: ()) - XCTAssertEqual(state, 1) - } + var state = 0 + _ = reducer.reduce(into: &state, action: ()) + XCTAssertEqual(state, 1) + } - #if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) - func testCombine_EffectsAreMerged() async throws { - if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { - try await _withMainSerialExecutor { - enum Action: Equatable { - case increment - } + #if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) + func testCombine_EffectsAreMerged() async throws { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + try await _withMainSerialExecutor { + enum Action: Equatable { + case increment + } - struct Delayed: ReducerProtocol { - typealias State = Int + struct Delayed: ReducerProtocol { + typealias State = Int - @Dependency(\.continuousClock) var clock + @Dependency(\.continuousClock) var clock - let delay: Duration - let setValue: @Sendable () async -> Void + let delay: Duration + let setValue: @Sendable () async -> Void - func reduce(into state: inout State, action: Action) -> EffectTask { - state += 1 - return .fireAndForget { - try await self.clock.sleep(for: self.delay) - await self.setValue() + func reduce(into state: inout State, action: Action) -> EffectTask { + state += 1 + return .fireAndForget { + try await self.clock.sleep(for: self.delay) + await self.setValue() + } } } - } - var fastValue: Int? = nil - var slowValue: Int? = nil + var fastValue: Int? = nil + var slowValue: Int? = nil - let clock = TestClock() + let clock = TestClock() - let store = TestStore( - initialState: 0, - reducer: CombineReducers { - Delayed(delay: .seconds(1), setValue: { @MainActor in fastValue = 42 }) - Delayed(delay: .seconds(2), setValue: { @MainActor in slowValue = 1729 }) + let store = TestStore( + initialState: 0, + reducer: CombineReducers { + Delayed(delay: .seconds(1), setValue: { @MainActor in fastValue = 42 }) + Delayed(delay: .seconds(2), setValue: { @MainActor in slowValue = 1729 }) + } + ) { + $0.continuousClock = clock } - ) { - $0.continuousClock = clock - } - await store.send(.increment) { - $0 = 2 + await store.send(.increment) { + $0 = 2 + } + // Waiting a second causes the fast effect to fire. + await clock.advance(by: .seconds(1)) + try await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) + XCTAssertEqual(fastValue, 42) + XCTAssertEqual(slowValue, nil) + // Waiting one more second causes the slow effect to fire. This proves that the effects + // are merged together, as opposed to concatenated. + await clock.advance(by: .seconds(1)) + await store.finish() + XCTAssertEqual(fastValue, 42) + XCTAssertEqual(slowValue, 1729) } - // Waiting a second causes the fast effect to fire. - await clock.advance(by: .seconds(1)) - try await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) - XCTAssertEqual(fastValue, 42) - XCTAssertEqual(slowValue, nil) - // Waiting one more second causes the slow effect to fire. This proves that the effects - // are merged together, as opposed to concatenated. - await clock.advance(by: .seconds(1)) - await store.finish() - XCTAssertEqual(fastValue, 42) - XCTAssertEqual(slowValue, 1729) } } - } - #endif + #endif - func testCombine() async { - enum Action: Equatable { - case increment - } + func testCombine() async { + enum Action: Equatable { + case increment + } - struct One: ReducerProtocol { - typealias State = Int - let effect: @Sendable () async -> Void - func reduce(into state: inout State, action: Action) -> EffectTask { - state += 1 - return .fireAndForget { - await self.effect() + struct One: ReducerProtocol { + typealias State = Int + let effect: @Sendable () async -> Void + func reduce(into state: inout State, action: Action) -> EffectTask { + state += 1 + return .fireAndForget { + await self.effect() + } } } - } - var first = false - var second = false + var first = false + var second = false - let store = TestStore( - initialState: 0, - reducer: CombineReducers { - One(effect: { @MainActor in first = true }) - One(effect: { @MainActor in second = true }) - } - ) + let store = TestStore( + initialState: 0, + reducer: CombineReducers { + One(effect: { @MainActor in first = true }) + One(effect: { @MainActor in second = true }) + } + ) - await store - .send(.increment) { $0 = 2 } - .finish() + await store + .send(.increment) { $0 = 2 } + .finish() - XCTAssertTrue(first) - XCTAssertTrue(second) - } + XCTAssertTrue(first) + XCTAssertTrue(second) + } #if canImport(os) @available(iOS 12.0, *) - func testDefaultSignpost() { - let reducer = EmptyReducer().signpost(log: .default) - var n = 0 - let effect = reducer.reduce(into: &n, action: ()) - let expectation = self.expectation(description: "effect") - effect + func testDefaultSignpost() { + let reducer = EmptyReducer().signpost(log: .default) + var n = 0 + let effect = reducer.reduce(into: &n, action: ()) + let expectation = self.expectation(description: "effect") + effect .producer .startWithCompleted { expectation.fulfill() } - self.wait(for: [expectation], timeout: 0.1) - } + self.wait(for: [expectation], timeout: 0.1) + } @available(iOS 12.0, *) - func testDisabledSignpost() { - let reducer = EmptyReducer().signpost(log: .disabled) - var n = 0 - let effect = reducer.reduce(into: &n, action: ()) - let expectation = self.expectation(description: "effect") - effect + func testDisabledSignpost() { + let reducer = EmptyReducer().signpost(log: .disabled) + var n = 0 + let effect = reducer.reduce(into: &n, action: ()) + let expectation = self.expectation(description: "effect") + effect .producer .startWithCompleted { expectation.fulfill() } - self.wait(for: [expectation], timeout: 0.1) - } + self.wait(for: [expectation], timeout: 0.1) + } #endif -} + } #endif 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 diff --git a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift index e6f91ffc6..97d8059bc 100644 --- a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift @@ -722,7 +722,7 @@ initialState: KrzysztofExample.State(), reducer: KrzysztofExample() ) { - $0.mainQueue = mainQueue + $0.mainQueueScheduler = mainQueue } store.exhaustivity = .off diff --git a/Tests/ComposableArchitectureTests/TestStoreTests.swift b/Tests/ComposableArchitectureTests/TestStoreTests.swift index 0a7bb0194..c03c50749 100644 --- a/Tests/ComposableArchitectureTests/TestStoreTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreTests.swift @@ -4,403 +4,403 @@ import XCTest // `@MainActor` introduces issues gathering tests on Linux #if !os(Linux) -@MainActor -final class TestStoreTests: XCTestCase { - func testEffectConcatenation() async { - struct State: Equatable {} + @MainActor + final class TestStoreTests: XCTestCase { + func testEffectConcatenation() async { + struct State: Equatable {} - enum Action: Equatable { - case a, b1, b2, b3, c1, c2, c3, d - } + enum Action: Equatable { + case a, b1, b2, b3, c1, c2, c3, d + } let mainQueue = TestScheduler() - let reducer = Reduce { _, action in - switch action { - case .a: - return .merge( - EffectTask.concatenate(.init(value: .b1), .init(value: .c1)) + let reducer = Reduce { _, action in + switch action { + case .a: + return .merge( + EffectTask.concatenate(.init(value: .b1), .init(value: .c1)) .deferred(for: 1, scheduler: mainQueue), Effect.none - .cancellable(id: 1) - ) - case .b1: - return - EffectTask - .concatenate(.init(value: .b2), .init(value: .b3)) - case .c1: - return - EffectTask - .concatenate(.init(value: .c2), .init(value: .c3)) - case .b2, .b3, .c2, .c3: - return .none - - case .d: - return .cancel(id: 1) - } - } + .cancellable(id: 1) + ) + case .b1: + return + EffectTask + .concatenate(.init(value: .b2), .init(value: .b3)) + case .c1: + return + EffectTask + .concatenate(.init(value: .c2), .init(value: .c3)) + case .b2, .b3, .c2, .c3: + return .none - let store = TestStore( - initialState: State(), - reducer: reducer - ) + case .d: + return .cancel(id: 1) + } + } - await store.send(.a) + let store = TestStore( + initialState: State(), + reducer: reducer + ) - await mainQueue.advance(by: 1) + await store.send(.a) - await store.receive(.b1) - await store.receive(.b2) - await store.receive(.b3) + await mainQueue.advance(by: 1) - await store.receive(.c1) - await store.receive(.c2) - await store.receive(.c3) + await store.receive(.b1) + await store.receive(.b2) + await store.receive(.b3) - await store.send(.d) - } + await store.receive(.c1) + await store.receive(.c2) + await store.receive(.c3) - func testAsync() async { - enum Action: Equatable { - case tap - case response(Int) + await store.send(.d) } - let store = TestStore( - initialState: 0, - reducer: Reduce { state, action in - switch action { - case .tap: - return .task { .response(42) } - case let .response(number): - state = number - return .none - } + + func testAsync() async { + enum Action: Equatable { + case tap + case response(Int) } - ) + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + switch action { + case .tap: + return .task { .response(42) } + case let .response(number): + state = number + return .none + } + } + ) - await store.send(.tap) - await store.receive(.response(42)) { - $0 = 42 + await store.send(.tap) + await store.receive(.response(42)) { + $0 = 42 + } } - } // `XCTExpectFailure` is not supported on Linux #if DEBUG && !os(Linux) - func testExpectedStateEquality() async { - struct State: Equatable { - var count: Int = 0 - var isChanging: Bool = false - } + func testExpectedStateEquality() async { + struct State: Equatable { + var count: Int = 0 + var isChanging: Bool = false + } - enum Action: Equatable { - case increment - case changed(from: Int, to: Int) - } + enum Action: Equatable { + case increment + case changed(from: Int, to: Int) + } - let reducer = Reduce { state, action in - switch action { - case .increment: - state.isChanging = true - return EffectTask(value: .changed(from: state.count, to: state.count + 1)) - case .changed(let from, let to): - state.isChanging = false - if state.count == from { - state.count = to + let reducer = Reduce { state, action in + switch action { + case .increment: + state.isChanging = true + return EffectTask(value: .changed(from: state.count, to: state.count + 1)) + case .changed(let from, let to): + state.isChanging = false + if state.count == from { + state.count = to + } + return .none } - return .none } - } - - let store = TestStore(initialState: State(), reducer: reducer) - await store.send(.increment) { - $0.isChanging = true - } - await store.receive(.changed(from: 0, to: 1)) { - $0.isChanging = false - $0.count = 1 - } + let store = TestStore(initialState: State(), reducer: reducer) - XCTExpectFailure { - _ = store.send(.increment) { + await store.send(.increment) { + $0.isChanging = true + } + await store.receive(.changed(from: 0, to: 1)) { $0.isChanging = false + $0.count = 1 } - } - XCTExpectFailure { - store.receive(.changed(from: 1, to: 2)) { - $0.isChanging = true - $0.count = 1100 + + XCTExpectFailure { + _ = store.send(.increment) { + $0.isChanging = false + } + } + XCTExpectFailure { + store.receive(.changed(from: 1, to: 2)) { + $0.isChanging = true + $0.count = 1100 + } } } - } - func testExpectedStateEqualityMustModify() async { - struct State: Equatable { - var count: Int = 0 - } + func testExpectedStateEqualityMustModify() async { + struct State: Equatable { + var count: Int = 0 + } - enum Action: Equatable { - case noop, finished - } + enum Action: Equatable { + case noop, finished + } - let reducer = Reduce { state, action in - switch action { - case .noop: - return EffectTask(value: .finished) - case .finished: - return .none + let reducer = Reduce { state, action in + switch action { + case .noop: + return EffectTask(value: .finished) + case .finished: + return .none + } } - } - let store = TestStore(initialState: State(), reducer: reducer) + let store = TestStore(initialState: State(), reducer: reducer) - await store.send(.noop) - await store.receive(.finished) + await store.send(.noop) + await store.receive(.finished) - XCTExpectFailure { - _ = store.send(.noop) { - $0.count = 0 + XCTExpectFailure { + _ = store.send(.noop) { + $0.count = 0 + } } - } - XCTExpectFailure { - store.receive(.finished) { - $0.count = 0 + XCTExpectFailure { + store.receive(.finished) { + $0.count = 0 + } } } - } - func testReceiveActionMatchingPredicate() async { - enum Action: Equatable { - case noop, finished - } + func testReceiveActionMatchingPredicate() async { + enum Action: Equatable { + case noop, finished + } - let reducer = Reduce { state, action in - switch action { - case .noop: - return EffectTask(value: .finished) - case .finished: - return .none + let reducer = Reduce { state, action in + switch action { + case .noop: + return EffectTask(value: .finished) + case .finished: + return .none + } } - } - let store = TestStore(initialState: 0, reducer: reducer) + let store = TestStore(initialState: 0, reducer: reducer) - let predicateShouldBeCalledExpectation = expectation( - description: "predicate should be called") - await store.send(.noop) - await store.receive { action in - predicateShouldBeCalledExpectation.fulfill() - return action == .finished - } - wait(for: [predicateShouldBeCalledExpectation], timeout: 0) + let predicateShouldBeCalledExpectation = expectation( + description: "predicate should be called") + await store.send(.noop) + await store.receive { action in + predicateShouldBeCalledExpectation.fulfill() + return action == .finished + } + wait(for: [predicateShouldBeCalledExpectation], timeout: 0) - XCTExpectFailure { - store.send(.noop) - store.receive(.noop) - } + XCTExpectFailure { + store.send(.noop) + store.receive(.noop) + } - XCTExpectFailure { - store.send(.noop) - store.receive { $0 == .noop } + XCTExpectFailure { + store.send(.noop) + store.receive { $0 == .noop } + } } - } - #endif - - func testStateAccess() async { - enum Action { case a, b, c, d } - let store = TestStore( - initialState: 0, - reducer: Reduce { count, action in - switch action { - case .a: - count += 1 + #endif + + func testStateAccess() async { + enum Action { case a, b, c, d } + let store = TestStore( + initialState: 0, + reducer: Reduce { count, action in + switch action { + case .a: + count += 1 return .merge(Effect(value: .b), Effect(value: .c), Effect(value: .d)) - case .b, .c, .d: - count += 1 - return .none + case .b, .c, .d: + count += 1 + return .none + } } - } - ) + ) - await store.send(.a) { - $0 = 1 - XCTAssertEqual(store.state, 0) - } - XCTAssertEqual(store.state, 1) - await store.receive(.b) { - $0 = 2 + await store.send(.a) { + $0 = 1 + XCTAssertEqual(store.state, 0) + } XCTAssertEqual(store.state, 1) - } - XCTAssertEqual(store.state, 2) - await store.receive(.c) { - $0 = 3 + await store.receive(.b) { + $0 = 2 + XCTAssertEqual(store.state, 1) + } XCTAssertEqual(store.state, 2) - } - XCTAssertEqual(store.state, 3) - await store.receive(.d) { - $0 = 4 + await store.receive(.c) { + $0 = 3 + XCTAssertEqual(store.state, 2) + } XCTAssertEqual(store.state, 3) + await store.receive(.d) { + $0 = 4 + XCTAssertEqual(store.state, 3) + } + XCTAssertEqual(store.state, 4) } - XCTAssertEqual(store.state, 4) - } - 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 = TestStore( - 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)) - ) + let store = TestStore( + 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)) + ) - store.send(true) { $0 = 1 } - } + store.send(true) { $0 = 1 } + } - func testOverrideDependenciesOnTestStore() { - 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 testOverrideDependenciesOnTestStore() { + struct Counter: ReducerProtocol { + @Dependency(\.calendar) var calendar + @Dependency(\.locale) var locale + @Dependency(\.timeZone) var timeZone + @Dependency(\.urlSession) var urlSession + + func reduce(into state: inout Int, action: Bool) -> EffectTask { + _ = self.calendar + _ = self.locale + _ = self.timeZone + _ = self.urlSession + state += action ? 1 : -1 + return .none + } } - } - let store = TestStore( - initialState: 0, - reducer: Counter() - ) - store.dependencies.calendar = Calendar(identifier: .gregorian) - store.dependencies.locale = Locale(identifier: "en_US") - store.dependencies.timeZone = TimeZone(secondsFromGMT: 0)! - store.dependencies.urlSession = URLSession(configuration: .ephemeral) + let store = TestStore( + initialState: 0, + reducer: Counter() + ) + store.dependencies.calendar = Calendar(identifier: .gregorian) + store.dependencies.locale = Locale(identifier: "en_US") + store.dependencies.timeZone = TimeZone(secondsFromGMT: 0)! + store.dependencies.urlSession = URLSession(configuration: .ephemeral) - store.send(true) { $0 = 1 } - } + store.send(true) { $0 = 1 } + } - func testOverrideDependenciesOnTestStore_MidwayChange() { - struct Counter: ReducerProtocol { - @Dependency(\.date.now) var now + func testOverrideDependenciesOnTestStore_MidwayChange() { + struct Counter: ReducerProtocol { + @Dependency(\.date.now) var now - func reduce(into state: inout Int, action: ()) -> EffectTask { - state = Int(self.now.timeIntervalSince1970) - return .none + func reduce(into state: inout Int, action: ()) -> EffectTask { + state = Int(self.now.timeIntervalSince1970) + return .none + } } - } - let store = TestStore( - initialState: 0, - reducer: Counter() - ) { - $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) - } + let store = TestStore( + initialState: 0, + reducer: Counter() + ) { + $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) + } - store.send(()) { $0 = 1_234_567_890 } + store.send(()) { $0 = 1_234_567_890 } - store.dependencies.date.now = Date(timeIntervalSince1970: 987_654_321) + store.dependencies.date.now = Date(timeIntervalSince1970: 987_654_321) - store.send(()) { $0 = 987_654_321 } - } + store.send(()) { $0 = 987_654_321 } + } + + func testOverrideDependenciesOnTestStore_Init() { + struct Counter: ReducerProtocol { + @Dependency(\.calendar) var calendar + @Dependency(\.locale) var locale + @Dependency(\.timeZone) var timeZone + @Dependency(\.urlSession) var urlSession + + func reduce(into state: inout Int, action: Bool) -> EffectTask { + _ = self.calendar + _ = self.locale + _ = self.timeZone + _ = self.urlSession + state += action ? 1 : -1 + return .none + } + } - func testOverrideDependenciesOnTestStore_Init() { - struct Counter: ReducerProtocol { - @Dependency(\.calendar) var calendar - @Dependency(\.locale) var locale - @Dependency(\.timeZone) var timeZone - @Dependency(\.urlSession) var urlSession - - func reduce(into state: inout Int, action: Bool) -> EffectTask { - _ = self.calendar - _ = self.locale - _ = self.timeZone - _ = self.urlSession - state += action ? 1 : -1 - return .none + let store = TestStore( + initialState: 0, + reducer: Counter() + ) { + $0.calendar = Calendar(identifier: .gregorian) + $0.locale = Locale(identifier: "en_US") + $0.timeZone = TimeZone(secondsFromGMT: 0)! + $0.urlSession = URLSession(configuration: .ephemeral) } - } - let store = TestStore( - initialState: 0, - reducer: Counter() - ) { - $0.calendar = Calendar(identifier: .gregorian) - $0.locale = Locale(identifier: "en_US") - $0.timeZone = TimeZone(secondsFromGMT: 0)! - $0.urlSession = URLSession(configuration: .ephemeral) + store.send(true) { $0 = 1 } } - store.send(true) { $0 = 1 } - } - - func testDependenciesEarlyBinding() async { - struct Feature: ReducerProtocol { - struct State: Equatable { - var count = 0 - var date: Date - init() { - @Dependency(\.date.now) var now: Date - self.date = now + func testDependenciesEarlyBinding() async { + struct Feature: ReducerProtocol { + struct State: Equatable { + var count = 0 + var date: Date + init() { + @Dependency(\.date.now) var now: Date + self.date = now + } } - } - enum Action: Equatable { - case tap - case response(Int) - } - @Dependency(\.date.now) var now: Date - func reduce(into state: inout State, action: Action) -> EffectTask { - switch action { - case .tap: - state.count += 1 - return .task { .response(42) } - case let .response(number): - state.count = number - state.date = now - return .none + enum Action: Equatable { + case tap + case response(Int) + } + @Dependency(\.date.now) var now: Date + func reduce(into state: inout State, action: Action) -> EffectTask { + switch action { + case .tap: + state.count += 1 + return .task { .response(42) } + case let .response(number): + state.count = number + state.date = now + return .none + } } } - } - let store = TestStore( - initialState: Feature.State(), - reducer: Feature() - ) { - $0.date = .constant(Date(timeIntervalSince1970: 1_234_567_890)) - } + let store = TestStore( + initialState: Feature.State(), + reducer: Feature() + ) { + $0.date = .constant(Date(timeIntervalSince1970: 1_234_567_890)) + } - await store.send(.tap) { - @Dependency(\.date.now) var now: Date - $0.count = 1 - $0.date = now - } - await store.receive(.response(42)) { - @Dependency(\.date.now) var now: Date - $0.count = 42 - $0.date = now + await store.send(.tap) { + @Dependency(\.date.now) var now: Date + $0.count = 1 + $0.date = now + } + await store.receive(.response(42)) { + @Dependency(\.date.now) var now: Date + $0.count = 42 + $0.date = now + } } } -} #endif diff --git a/Tests/ComposableArchitectureTests/ViewStoreTests.swift b/Tests/ComposableArchitectureTests/ViewStoreTests.swift index 83bd3382e..d270f2061 100644 --- a/Tests/ComposableArchitectureTests/ViewStoreTests.swift +++ b/Tests/ComposableArchitectureTests/ViewStoreTests.swift @@ -8,50 +8,50 @@ import XCTest // `@MainActor` introduces issues gathering tests on Linux #if !os(Linux) -@MainActor -final class ViewStoreTests: XCTestCase { - override func setUp() { - super.setUp() - equalityChecks = 0 - subEqualityChecks = 0 - } + @MainActor + final class ViewStoreTests: XCTestCase { + override func setUp() { + super.setUp() + equalityChecks = 0 + subEqualityChecks = 0 + } - func testPublisherFirehose() { - let store = Store( - initialState: 0, - reducer: EmptyReducer() - ) + func testPublisherFirehose() { + let store = Store( + initialState: 0, + reducer: EmptyReducer() + ) - let viewStore = ViewStore(store, observe: { $0 }) + let viewStore = ViewStore(store, observe: { $0 }) - var emissionCount = 0 + var emissionCount = 0 viewStore.produced.producer .startWithValues { _ in emissionCount += 1 } - XCTAssertEqual(emissionCount, 1) - viewStore.send(()) - XCTAssertEqual(emissionCount, 1) - viewStore.send(()) - XCTAssertEqual(emissionCount, 1) - viewStore.send(()) - XCTAssertEqual(emissionCount, 1) - } + XCTAssertEqual(emissionCount, 1) + viewStore.send(()) + XCTAssertEqual(emissionCount, 1) + viewStore.send(()) + XCTAssertEqual(emissionCount, 1) + viewStore.send(()) + XCTAssertEqual(emissionCount, 1) + } - func testEqualityChecks() { - let store = Store( - initialState: State(), - reducer: EmptyReducer() - ) + func testEqualityChecks() { + let store = Store( + initialState: State(), + reducer: EmptyReducer() + ) - let store1 = store.scope(state: { $0 }) - let store2 = store1.scope(state: { $0 }) - let store3 = store2.scope(state: { $0 }) - let store4 = store3.scope(state: { $0 }) + let store1 = store.scope(state: { $0 }) + let store2 = store1.scope(state: { $0 }) + let store3 = store2.scope(state: { $0 }) + let store4 = store3.scope(state: { $0 }) - let viewStore1 = ViewStore(store1) - let viewStore2 = ViewStore(store2) - let viewStore3 = ViewStore(store3) - let viewStore4 = ViewStore(store4) + let viewStore1 = ViewStore(store1) + let viewStore2 = ViewStore(store2) + let viewStore3 = ViewStore(store3) + let viewStore4 = ViewStore(store4) viewStore1.produced.producer.startWithValues { _ in } viewStore2.produced.producer.startWithValues { _ in } @@ -62,97 +62,97 @@ final class ViewStoreTests: XCTestCase { viewStore3.produced.substate.startWithValues { _ in } viewStore4.produced.substate.startWithValues { _ in } - XCTAssertEqual(0, equalityChecks) - XCTAssertEqual(0, subEqualityChecks) - viewStore4.send(()) - XCTAssertEqual(4, equalityChecks) - XCTAssertEqual(4, subEqualityChecks) - viewStore4.send(()) - XCTAssertEqual(8, equalityChecks) - XCTAssertEqual(8, subEqualityChecks) - viewStore4.send(()) - XCTAssertEqual(12, equalityChecks) - XCTAssertEqual(12, subEqualityChecks) - viewStore4.send(()) - XCTAssertEqual(16, equalityChecks) - XCTAssertEqual(16, subEqualityChecks) - } - - func testAccessViewStoreStateInPublisherSink() { - let reducer = Reduce { count, _ in - count += 1 - return .none + XCTAssertEqual(0, equalityChecks) + XCTAssertEqual(0, subEqualityChecks) + viewStore4.send(()) + XCTAssertEqual(4, equalityChecks) + XCTAssertEqual(4, subEqualityChecks) + viewStore4.send(()) + XCTAssertEqual(8, equalityChecks) + XCTAssertEqual(8, subEqualityChecks) + viewStore4.send(()) + XCTAssertEqual(12, equalityChecks) + XCTAssertEqual(12, subEqualityChecks) + viewStore4.send(()) + XCTAssertEqual(16, equalityChecks) + XCTAssertEqual(16, subEqualityChecks) } - let store = Store(initialState: 0, reducer: reducer) - let viewStore = ViewStore(store, observe: { $0 }) + func testAccessViewStoreStateInPublisherSink() { + let reducer = Reduce { count, _ in + count += 1 + return .none + } - var results: [Int] = [] + let store = Store(initialState: 0, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) + + var results: [Int] = [] viewStore.produced.producer .startWithValues { _ in results.append(viewStore.state) } - viewStore.send(()) - viewStore.send(()) - viewStore.send(()) + viewStore.send(()) + viewStore.send(()) + viewStore.send(()) - XCTAssertEqual([0, 1, 2, 3], results) - } + XCTAssertEqual([0, 1, 2, 3], results) + } #if canImport(Combine) - func testWillSet() { + func testWillSet() { var cancellables: Set = [] - let reducer = Reduce { count, _ in - count += 1 - return .none - } + let reducer = Reduce { count, _ in + count += 1 + return .none + } - let store = Store(initialState: 0, reducer: reducer) - let viewStore = ViewStore(store, observe: { $0 }) + let store = Store(initialState: 0, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) - var results: [Int] = [] + var results: [Int] = [] - viewStore.objectWillChange - .sink { _ in results.append(viewStore.state) } + viewStore.objectWillChange + .sink { _ in results.append(viewStore.state) } .store(in: &cancellables) - viewStore.send(()) - viewStore.send(()) - viewStore.send(()) + viewStore.send(()) + viewStore.send(()) + viewStore.send(()) - XCTAssertEqual([0, 1, 2], results) - } + XCTAssertEqual([0, 1, 2], results) + } #endif // disabled as the fix for this would be onerous with // ReactiveSwift, forcing explicit disposable of any use of // `ViewStore.produced.producer` func disabled_testPublisherOwnsViewStore() { - let reducer = Reduce { count, _ in - count += 1 - return .none - } - let store = Store(initialState: 0, reducer: reducer) + let reducer = Reduce { count, _ in + count += 1 + return .none + } + let store = Store(initialState: 0, reducer: reducer) - var results: [Int] = [] - ViewStore(store, observe: { $0 }) + var results: [Int] = [] + ViewStore(store, observe: { $0 }) .produced.producer .startWithValues { results.append($0) } - ViewStore(store, observe: { $0 }).send(()) - XCTAssertEqual(results, [0, 1]) - } - - func testStorePublisherSubscriptionOrder() { - let reducer = Reduce { count, _ in - count += 1 - return .none + ViewStore(store, observe: { $0 }).send(()) + XCTAssertEqual(results, [0, 1]) } - let store = Store(initialState: 0, reducer: reducer) - let viewStore = ViewStore(store, observe: { $0 }) - var results: [Int] = [] + func testStorePublisherSubscriptionOrder() { + let reducer = Reduce { count, _ in + count += 1 + return .none + } + let store = Store(initialState: 0, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) + + var results: [Int] = [] viewStore.produced.producer .startWithValues { _ in results.append(0) } @@ -163,147 +163,147 @@ final class ViewStoreTests: XCTestCase { viewStore.produced.producer .startWithValues { _ in results.append(2) } - XCTAssertEqual(results, [0, 1, 2]) + XCTAssertEqual(results, [0, 1, 2]) - for _ in 0..<9 { - viewStore.send(()) - } + for _ in 0..<9 { + viewStore.send(()) + } - XCTAssertEqual(results, Array(repeating: [0, 1, 2], count: 10).flatMap { $0 }) - } + XCTAssertEqual(results, Array(repeating: [0, 1, 2], count: 10).flatMap { $0 }) + } #if canImport(_Concurrency) && compiler(>=5.5.2) func testSendWhile() async { - enum Action { - case response - case tapped - } - let reducer = Reduce { state, action in - switch action { - case .response: - state = false - return .none - case .tapped: - state = true - return .task { .response } + enum Action { + case response + case tapped + } + let reducer = Reduce { state, action in + switch action { + case .response: + state = false + return .none + case .tapped: + state = true + return .task { .response } + } } - } - let store = Store(initialState: false, reducer: reducer) - let viewStore = ViewStore(store, observe: { $0 }) + let store = Store(initialState: false, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) - XCTAssertEqual(viewStore.state, false) - await viewStore.send(.tapped, while: { $0 }) - XCTAssertEqual(viewStore.state, false) - } + XCTAssertEqual(viewStore.state, false) + await viewStore.send(.tapped, while: { $0 }) + XCTAssertEqual(viewStore.state, false) + } func testSuspend() async { - let expectation = self.expectation(description: "await") - Task { - enum Action { - case response - case tapped - } - let reducer = Reduce { state, action in - switch action { - case .response: - state = false - return .none - case .tapped: - state = true - return .task { .response } - } - } + let expectation = self.expectation(description: "await") + Task { + enum Action { + case response + case tapped + } + let reducer = Reduce { state, action in + switch action { + case .response: + state = false + return .none + case .tapped: + state = true + return .task { .response } + } + } - let store = Store(initialState: false, reducer: reducer) - let viewStore = ViewStore(store, observe: { $0 }) + let store = Store(initialState: false, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) - XCTAssertEqual(viewStore.state, false) - _ = { viewStore.send(.tapped) }() - XCTAssertEqual(viewStore.state, true) - await viewStore.yield(while: { $0 }) - XCTAssertEqual(viewStore.state, false) - expectation.fulfill() - } + XCTAssertEqual(viewStore.state, false) + _ = { viewStore.send(.tapped) }() + XCTAssertEqual(viewStore.state, true) + await viewStore.yield(while: { $0 }) + XCTAssertEqual(viewStore.state, false) + expectation.fulfill() + } _ = XCTWaiter.wait(for: [expectation], timeout: 1) - } + } - func testAsyncSend() async throws { - enum Action { - case tap - case response(Int) - } - let store = Store( - initialState: 0, - reducer: Reduce { state, action in - switch action { - case .tap: - return .task { - return .response(42) - } - case let .response(value): - state = value - return .none + func testAsyncSend() async throws { + enum Action { + case tap + case response(Int) } - } - ) + let store = Store( + initialState: 0, + reducer: Reduce { state, action in + switch action { + case .tap: + return .task { + return .response(42) + } + case let .response(value): + state = value + return .none + } + } + ) - let viewStore = ViewStore(store, observe: { $0 }) + let viewStore = ViewStore(store, observe: { $0 }) - XCTAssertEqual(viewStore.state, 0) - await viewStore.send(.tap).finish() - XCTAssertEqual(viewStore.state, 42) - } + XCTAssertEqual(viewStore.state, 0) + await viewStore.send(.tap).finish() + XCTAssertEqual(viewStore.state, 42) + } - func testAsyncSendCancellation() async throws { - enum Action { - case tap - case response(Int) - } - let store = Store( - initialState: 0, - reducer: Reduce { state, action in - switch action { - case .tap: - return .task { - try await Task.sleep(nanoseconds: NSEC_PER_SEC) - return .response(42) - } - case let .response(value): - state = value - return .none + func testAsyncSendCancellation() async throws { + enum Action { + case tap + case response(Int) } - } - ) + let store = Store( + initialState: 0, + reducer: Reduce { state, action in + switch action { + case .tap: + return .task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return .response(42) + } + case let .response(value): + state = value + return .none + } + } + ) - let viewStore = ViewStore(store, observe: { $0 }) + let viewStore = ViewStore(store, observe: { $0 }) - XCTAssertEqual(viewStore.state, 0) - let task = viewStore.send(.tap) - await task.cancel() - try await Task.sleep(nanoseconds: NSEC_PER_MSEC) - XCTAssertEqual(viewStore.state, 0) - } + XCTAssertEqual(viewStore.state, 0) + let task = viewStore.send(.tap) + await task.cancel() + try await Task.sleep(nanoseconds: NSEC_PER_MSEC) + XCTAssertEqual(viewStore.state, 0) + } #endif -} + } -private struct State: Equatable { - var substate = Substate() + private struct State: Equatable { + var substate = Substate() - static func == (lhs: Self, rhs: Self) -> Bool { - equalityChecks += 1 - return lhs.substate == rhs.substate + static func == (lhs: Self, rhs: Self) -> Bool { + equalityChecks += 1 + return lhs.substate == rhs.substate + } } -} -private struct Substate: Equatable { - var name = "Blob" + private struct Substate: Equatable { + var name = "Blob" - static func == (lhs: Self, rhs: Self) -> Bool { - subEqualityChecks += 1 - return lhs.name == rhs.name + static func == (lhs: Self, rhs: Self) -> Bool { + subEqualityChecks += 1 + return lhs.name == rhs.name + } } -} #endif private var equalityChecks = 0