Files
swift-composable-architectu…/Sources/ComposableArchitecture/Effect.swift
Brandon Williams 2c93195c23 Prerelease 1.0 (#1929)
* Converted voice memos back to identified array

* update deps

* update docs for DismissEffect

* wip

* Add Sendable conformance to PresentationState (#2086)

* wip

* swift-format

* wip

* wip

* fix some warnings

* docs

* wip

* wip

* Catch some typos in Articles (#2088)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* docs

* wip

* wip

* docs

* wip

* wip

* wip

* wip

* docs

* docs

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Fix invalid states count for 3 optionals and typos (#2094)

* wip

* wip

* more dismisseffect docs

* fixed some references

* navigation doc corrections

* more nav docs

* fix cancellation tests in release mode

* wrap some tests in #if DEBUG since they are testing expected failures

* update UUIDs in tests to use shorter initializer

* fixed a todo

* wip

* fix merge errors

* wip

* fix

* wip

* wip

* fixing a bunch of todos

* get rid of rawvalue in StackElementID

* more todos

* NavLinkStore docs

* fix swift 5.6 stuff

* fix some standups tests

* fix

* clean up

* docs fix

* fixes

* wip

* 5.6 fix

* wip

* wip

* dont parallelize tests

* updated demo readmes

* wip

* Use ObservedObject instead of StateObject for alert/dialog modifiers.

* integration tests for bad dismissal behavior

* check for runtime warnings in every integration test

* wip

* wip

* wip

* fix

* wip

* wip

* wip

* wip

* wip

* wip

* Drop a bunch of Hashables.

* some nav bug fixes

* wip

* wip

* wip

* fix

* fix

* wip

* wip

* Simplify recording test.

* add concurrent async test

* fix

* wip

* Refact how detail dismisses itself.

* fix

* 5.6 fix

* wip

* wip

* wip

* wip

* Add TestStore.assert.

* Revert "Add TestStore.assert."

This reverts commit a892cccc66.

* add Ukrainian Readme.md (#2121)

* Add TestStore.assert. (#2123)

* Add TestStore.assert.

* wip

* Update Sources/ComposableArchitecture/TestStore.swift

Co-authored-by: Stephen Celis <stephen@stephencelis.com>

* Update Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md

Co-authored-by: Stephen Celis <stephen@stephencelis.com>

* fix tests

---------

Co-authored-by: Stephen Celis <stephen@stephencelis.com>

* Run swift-format

* push for store.finish and presentation

* wip

* move docs around

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Add case subscripts

* wip

* wip

* wip

* 5.7-only

* wip

* wip

* wip

* wip

* fix

* revert store.finish task cancellation

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* add test for presentation scope

* wip

* wip

* wip

* wip

* wip

* cleanup

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Rename ReducerProtocol.swift to Reducer.swift (#2206)

* Hard-deprecate old SwitchStore initializers/overloads

* wip

* wip

* Resolve CaseStudies crash (#2258)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Bump timeout for CI

* wip

* wip

---------

Co-authored-by: Jackson Utsch <jutechs@gmail.com>
Co-authored-by: Stephen Celis <stephen@stephencelis.com>
Co-authored-by: 유재호 <y73447jh@gmail.com>
Co-authored-by: Dmytro <barabashdmyto@gmail.com>
Co-authored-by: mbrandonw <mbrandonw@users.noreply.github.com>
2023-07-27 17:35:07 -07:00

581 lines
20 KiB
Swift

import Combine
import Foundation
import SwiftUI
import XCTestDynamicOverlay
/// This type is deprecated in favor of ``Effect``. See its documentation for more information.
@available(
iOS,
deprecated: 9999,
message:
"""
'EffectPublisher' has been deprecated in favor of 'Effect'.
You are encouraged to use `Effect<Action>` to model the output of your reducers, and to use Swift concurrency to model asynchrony in dependencies.
See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477
"""
)
@available(
macOS,
deprecated: 9999,
message:
"""
'EffectPublisher' has been deprecated in favor of 'Effect'.
You are encouraged to use `Effect<Action>` to model the output of your reducers, and to use Swift concurrency to model asynchrony in dependencies.
See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477
"""
)
@available(
tvOS,
deprecated: 9999,
message:
"""
'EffectPublisher' has been deprecated in favor of 'Effect'.
You are encouraged to use `Effect<Action>` to model the output of your reducers, and to use Swift concurrency to model asynchrony in dependencies.
See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477
"""
)
@available(
watchOS,
deprecated: 9999,
message:
"""
'EffectPublisher' has been deprecated in favor of 'Effect'.
You are encouraged to use `Effect<Action>` to model the output of your reducers, and to use Swift concurrency to model asynchrony in dependencies.
See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477
"""
)
public struct EffectPublisher<Action, Failure: Error> {
@usableFromInline
enum Operation {
case none
case publisher(AnyPublisher<Action, Failure>)
case run(TaskPriority? = nil, @Sendable (_ send: Send<Action>) async -> Void)
}
@usableFromInline
let operation: Operation
@usableFromInline
init(operation: Operation) {
self.operation = operation
}
}
/// A convenience type alias for referring to an effect of a given reducer's domain.
///
/// Instead of specifying the action:
///
/// ```swift
/// let effect: EffectTask<Feature.Action>
/// ```
///
/// You can specify the reducer:
///
/// ```swift
/// let effect: EffectOf<Feature>
/// ```
public typealias EffectOf<R: Reducer> = EffectPublisher<R.Action, Never>
// MARK: - Creating Effects
extension EffectPublisher {
/// An effect that does nothing and completes immediately. Useful for situations where you must
/// return an effect, but you don't need to do anything.
@inlinable
public static var none: Self {
Self(operation: .none)
}
}
/// A type that encapsulates a unit of work that can be run in the outside world, and can feed
/// actions back to the ``Store``.
///
/// Effects are the perfect place to do side effects, such as network requests, saving/loading
/// from disk, creating timers, interacting with dependencies, and more. They are returned from
/// reducers so that the ``Store`` can perform the effects after the reducer is done running.
///
/// There are 2 distinct ways to create an `Effect`: one using Swift's native concurrency tools, and
/// the other using Apple's Combine framework:
///
/// * If using Swift's native structured concurrency tools then there is one main way to create an
/// effect: ``EffectPublisher/run(priority:operation:catch:fileID:line:)``.
///
/// * If using Combine in your application, in particular for the dependencies of your feature
/// then you can create effects by making use of any of Combine's operators, and then erasing the
/// publisher type to ``EffectPublisher`` with either `eraseToEffect` or `catchToEffect`. Note that
/// the Combine interface to ``EffectPublisher`` is considered soft deprecated, and you should
/// eventually port to Swift's native concurrency tools.
///
/// > Important: The publisher interface to ``Effect`` is considered deprecated, and you should try
/// > converting any uses of that interface to Swift's native concurrency tools.
/// >
/// > Also, ``Store`` is not thread safe, and so all effects must receive values on the same
/// > thread. This is typically the main thread, **and** if the store is being used to drive UI
/// > then it must receive values on the main thread.
/// >
/// > This is only an issue if using the Combine interface of ``EffectPublisher`` as mentioned
/// > above. If you are using Swift's concurrency tools and the `.run` function on ``Effect``,
/// > then threading is automatically handled for you.
public typealias Effect<Action> = EffectPublisher<Action, Never>
extension EffectPublisher where Failure == Never {
/// Wraps an asynchronous unit of work that can emit actions any number of times in an effect.
///
/// For example, if you had an async stream in a dependency client:
///
/// ```swift
/// struct EventsClient {
/// var events: () -> AsyncStream<Event>
/// }
/// ```
///
/// Then you could attach to it in a `run` effect by using `for await` and sending each action of
/// the stream back into the system:
///
/// ```swift
/// case .startButtonTapped:
/// return .run { send in
/// for await event in self.events() {
/// send(.event(event))
/// }
/// }
/// ```
///
/// See ``Send`` for more information on how to use the `send` argument passed to `run`'s closure.
///
/// The closure provided to ``run(priority:operation:catch:fileID:line:)`` is allowed to
/// throw, but any non-cancellation errors thrown will cause a runtime warning when run in the
/// simulator or on a device, and will cause a test failure in tests. To catch non-cancellation
/// errors use the `catch` trailing closure.
///
/// - Parameters:
/// - priority: Priority of the underlying task. If `nil`, the priority will come from
/// `Task.currentPriority`.
/// - operation: The operation to execute.
/// - catch: An error handler, invoked if the operation throws an error other than
/// `CancellationError`.
/// - Returns: An effect wrapping the given asynchronous work.
public static func run(
priority: TaskPriority? = nil,
operation: @escaping @Sendable (_ send: Send<Action>) async throws -> Void,
catch handler: (@Sendable (_ error: Error, _ send: Send<Action>) async -> Void)? = nil,
fileID: StaticString = #fileID,
line: UInt = #line
) -> Self {
withEscapedDependencies { escaped in
Self(
operation: .run(priority) { send in
await escaped.yield {
do {
try await operation(send)
} catch is CancellationError {
return
} catch {
guard let handler = handler else {
#if DEBUG
var errorDump = ""
customDump(error, to: &errorDump, indent: 4)
runtimeWarn(
"""
An "Effect.run" returned from "\(fileID):\(line)" threw an unhandled error. …
\(errorDump)
All non-cancellation errors must be explicitly handled via the "catch" parameter \
on "Effect.run", or via a "do" block.
"""
)
#endif
return
}
await handler(error, send)
}
}
}
)
}
}
/// 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 <doc:Performance#Sharing-logic-with-actions>.
///
/// - Parameter action: The action that is immediately emitted by the effect.
public static func send(_ action: Action) -> Self {
Self(operation: .publisher(Just(action).eraseToAnyPublisher()))
}
/// 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 <doc:Performance#Sharing-logic-with-actions>.
///
/// - Parameters:
/// - action: The action that is immediately emitted by the effect.
/// - animation: An animation.
public static func send(_ action: Action, animation: Animation? = nil) -> Self {
.send(action).animation(animation)
}
}
/// A type that can send actions back into the system when used from
/// ``EffectPublisher/run(priority:operation:catch:fileID:line:)``.
///
/// This type implements [`callAsFunction`][callAsFunction] so that you invoke it as a function
/// rather than calling methods on it:
///
/// ```swift
/// return .run { send in
/// send(.started)
/// defer { send(.finished) }
/// for await event in self.events {
/// send(.event(event))
/// }
/// }
/// ```
///
/// You can also send actions with animation:
///
/// ```swift
/// send(.started, animation: .spring())
/// defer { send(.finished, animation: .default) }
/// ```
///
/// See ``EffectPublisher/run(priority:operation:catch:fileID:line:)`` for more information on how to
/// use this value to construct effects that can emit any number of times in an asynchronous
/// context.
///
/// [callAsFunction]: https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#ID622
@MainActor
public struct Send<Action>: Sendable {
let send: @MainActor @Sendable (Action) -> Void
public init(send: @escaping @MainActor @Sendable (Action) -> Void) {
self.send = send
}
/// Sends an action back into the system from an effect.
///
/// - Parameter action: An action.
public func callAsFunction(_ action: Action) {
guard !Task.isCancelled else { return }
self.send(action)
}
/// 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:
/// - action: An action.
/// - transaction: A transaction.
public func callAsFunction(_ action: Action, transaction: Transaction) {
guard !Task.isCancelled else { return }
withTransaction(transaction) {
self(action)
}
}
}
// MARK: - Composing Effects
extension EffectPublisher {
/// Merges a variadic list of effects together into a single effect, which runs the effects at the
/// same time.
///
/// - Parameter effects: A variadic list of effects.
/// - Returns: A new effect
@inlinable
public static func merge(_ effects: Self...) -> Self {
Self.merge(effects)
}
/// Merges a sequence of effects together into a single effect, which runs the effects at the same
/// time.
///
/// - Parameter effects: A sequence of effects.
/// - Returns: A new effect
@inlinable
public static func merge<S: Sequence>(_ effects: S) -> Self where S.Element == Self {
effects.reduce(.none) { $0.merge(with: $1) }
}
/// Merges this effect and another into a single effect that runs both at the same time.
///
/// - Parameter other: Another effect.
/// - Returns: An effect that runs this effect and the other at the same time.
@inlinable
public func merge(with other: Self) -> Self {
switch (self.operation, other.operation) {
case (_, .none):
return self
case (.none, _):
return other
case (.publisher, .publisher), (.run, .publisher), (.publisher, .run):
return Self(
operation: .publisher(
Publishers.Merge(
EffectPublisherWrapper(self),
EffectPublisherWrapper(other)
)
.eraseToAnyPublisher()
)
)
case let (.run(lhsPriority, lhsOperation), .run(rhsPriority, rhsOperation)):
return Self(
operation: .run { send in
await withTaskGroup(of: Void.self) { group in
group.addTask(priority: lhsPriority) {
await lhsOperation(send)
}
group.addTask(priority: rhsPriority) {
await rhsOperation(send)
}
}
}
)
}
}
/// Concatenates a variadic list of effects together into a single effect, which runs the effects
/// one after the other.
///
/// - Parameter effects: A variadic list of effects.
/// - Returns: A new effect
@inlinable
public static func concatenate(_ effects: Self...) -> Self {
Self.concatenate(effects)
}
/// Concatenates a collection of effects together into a single effect, which runs the effects one
/// after the other.
///
/// - Parameter effects: A collection of effects.
/// - Returns: A new effect
@inlinable
public static func concatenate<C: Collection>(_ effects: C) -> Self where C.Element == Self {
effects.reduce(.none) { $0.concatenate(with: $1) }
}
/// Concatenates this effect and another into a single effect that first runs this effect, and
/// after it completes or is cancelled, runs the other.
///
/// - Parameter other: Another effect.
/// - Returns: An effect that runs this effect, and after it completes or is cancelled, runs the
/// other.
@inlinable
@_disfavoredOverload
public func concatenate(with other: Self) -> Self {
switch (self.operation, other.operation) {
case (_, .none):
return self
case (.none, _):
return other
case (.publisher, .publisher), (.run, .publisher), (.publisher, .run):
return Self(
operation: .publisher(
Publishers.Concatenate(
prefix: EffectPublisherWrapper(self),
suffix: EffectPublisherWrapper(other)
)
.eraseToAnyPublisher()
)
)
case let (.run(lhsPriority, lhsOperation), .run(rhsPriority, rhsOperation)):
return Self(
operation: .run { send in
if let lhsPriority = lhsPriority {
await Task(priority: lhsPriority) { await lhsOperation(send) }.cancellableValue
} else {
await lhsOperation(send)
}
if let rhsPriority = rhsPriority {
await Task(priority: rhsPriority) { await rhsOperation(send) }.cancellableValue
} else {
await rhsOperation(send)
}
}
)
}
}
/// Transforms all elements from the upstream effect with a provided closure.
///
/// - Parameter transform: A closure that transforms the upstream effect's action to a new action.
/// - Returns: A publisher that uses the provided closure to map elements from the upstream effect
/// to new elements that it then publishes.
@inlinable
public func map<T>(_ transform: @escaping (Action) -> T) -> EffectPublisher<T, Failure> {
switch self.operation {
case .none:
return .none
case let .publisher(publisher):
return .init(
operation: .publisher(
publisher
.map(
withEscapedDependencies { escaped in
{ action in
escaped.yield {
transform(action)
}
}
}
)
.eraseToAnyPublisher()
)
)
case let .run(priority, operation):
return withEscapedDependencies { escaped in
.init(
operation: .run(priority) { send in
await escaped.yield {
await operation(
Send { action in
send(transform(action))
}
)
}
}
)
}
}
}
}
// MARK: - Testing Effects
extension EffectPublisher {
/// An effect that causes a test to fail if it runs.
///
/// > Important: This Combine-based interface has been soft-deprecated in favor of Swift
/// > concurrency. Prefer using async functions and `AsyncStream`s directly in your dependencies,
/// > and using `unimplemented` from the [XCTest Dynamic Overlay](gh-xctest-dynamic-overlay)
/// > library to stub in a function that fails when invoked:
/// >
/// > ```swift
/// > struct NumberFactClient {
/// > var fetch: (Int) async throws -> String
/// > }
/// >
/// > extension NumberFactClient: TestDependencyKey {
/// > static let testValue = Self(
/// > fetch: unimplemented(
/// > "\(Self.self).fetch",
/// > placeholder: "Not an interesting number."
/// > )
/// > }
/// > }
/// > ```
///
/// This effect can provide an additional layer of certainty that a tested code path does not
/// execute a particular effect.
///
/// For example, let's say we have a very simple counter application, where a user can increment
/// and decrement a number. The state and actions are simple enough:
///
/// ```swift
/// struct CounterState: Equatable {
/// var count = 0
/// }
///
/// enum CounterAction: Equatable {
/// case decrementButtonTapped
/// case incrementButtonTapped
/// }
/// ```
///
/// Let's throw in a side effect. If the user attempts to decrement the counter below zero, the
/// application should refuse and play an alert sound instead.
///
/// We can model playing a sound in the environment with an effect:
///
/// ```swift
/// struct CounterEnvironment {
/// let playAlertSound: () -> EffectPublisher<Never, Never>
/// }
/// ```
///
/// Now that we've defined the domain, we can describe the logic in a reducer:
///
/// ```swift
/// let counterReducer = AnyReducer<
/// CounterState, CounterAction, CounterEnvironment
/// > { state, action, environment in
/// switch action {
/// case .decrementButtonTapped:
/// if state > 0 {
/// state.count -= 0
/// return .none
/// } else {
/// return environment.playAlertSound()
/// .fireAndForget()
/// }
///
/// case .incrementButtonTapped:
/// state.count += 1
/// return .none
/// }
/// }
/// ```
///
/// Let's say we want to write a test for the increment path. We can see in the reducer that it
/// should never play an alert, so we can configure the environment with an effect that will
/// fail if it ever executes:
///
/// ```swift
/// @MainActor
/// func testIncrement() async {
/// let store = TestStore(
/// initialState: CounterState(count: 0)
/// reducer: counterReducer,
/// environment: CounterEnvironment(
/// playSound: .unimplemented("playSound")
/// )
/// )
///
/// await store.send(.increment) {
/// $0.count = 1
/// }
/// }
/// ```
///
/// By using an `.unimplemented` effect in our environment we have strengthened the assertion and
/// made the test easier to understand at the same time. We can see, without consulting the
/// reducer itself, that this particular action should not access this effect.
///
/// [gh-xctest-dynamic-overlay]: http://github.com/pointfreeco/xctest-dynamic-overlay
///
/// - Parameter prefix: A string that identifies this effect and will prefix all failure
/// messages.
/// - Returns: An effect that causes a test to fail if it runs.
@available(*, deprecated, message: "Call 'unimplemented' from your dependencies, instead.")
public static func unimplemented(_ prefix: String) -> Self {
.fireAndForget {
XCTFail("\(prefix.isEmpty ? "" : "\(prefix) - ")An unimplemented effect ran.")
}
}
}