mirror of
https://github.com/pointfreeco/swift-composable-architecture.git
synced 2025-12-24 12:14:25 +01:00
* 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>
517 lines
17 KiB
Swift
517 lines
17 KiB
Swift
import Combine
|
|
|
|
extension EffectPublisher where Failure == Never {
|
|
/// Creates an effect from a Combine publisher.
|
|
///
|
|
/// - Parameter createPublisher: The closure to execute when the effect is performed.
|
|
/// - Returns: An effect wrapping a Combine publisher.
|
|
public static func publisher<P: Publisher>(_ createPublisher: @escaping () -> P) -> Self
|
|
where P.Output == Action, P.Failure == Never {
|
|
Self(
|
|
operation: .publisher(
|
|
withEscapedDependencies { continuation in
|
|
Deferred {
|
|
continuation.yield {
|
|
createPublisher()
|
|
}
|
|
}
|
|
}
|
|
.eraseToAnyPublisher()
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
@available(*, deprecated)
|
|
extension EffectPublisher: Publisher {
|
|
public typealias Output = Action
|
|
|
|
public func receive<S: Combine.Subscriber>(
|
|
subscriber: S
|
|
) where S.Input == Action, S.Failure == Failure {
|
|
self.publisher.subscribe(subscriber)
|
|
}
|
|
|
|
var publisher: AnyPublisher<Action, Failure> {
|
|
switch self.operation {
|
|
case .none:
|
|
return Empty().eraseToAnyPublisher()
|
|
case let .publisher(publisher):
|
|
return publisher
|
|
case let .run(priority, operation):
|
|
return .create { subscriber in
|
|
let task = Task(priority: priority) { @MainActor in
|
|
defer { subscriber.send(completion: .finished) }
|
|
#if DEBUG
|
|
let isCompleted = LockIsolated(false)
|
|
defer { isCompleted.setValue(true) }
|
|
#endif
|
|
let send = Send<Action> {
|
|
#if DEBUG
|
|
if isCompleted.value {
|
|
runtimeWarn(
|
|
"""
|
|
An action was sent from a completed effect:
|
|
|
|
Action:
|
|
\(debugCaseOutput($0))
|
|
|
|
Avoid sending actions using the 'send' argument from 'Effect.run' after \
|
|
the effect has completed. This can happen if you escape the 'send' argument in \
|
|
an unstructured context.
|
|
|
|
To fix this, make sure that your 'run' closure does not return until you're \
|
|
done calling 'send'.
|
|
"""
|
|
)
|
|
}
|
|
#endif
|
|
subscriber.send($0)
|
|
}
|
|
await operation(send)
|
|
}
|
|
return AnyCancellable {
|
|
task.cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension EffectPublisher {
|
|
/// Initializes an effect that wraps a publisher.
|
|
///
|
|
/// > Important: This Combine interface has been soft-deprecated in favor of Swift concurrency.
|
|
/// > Prefer performing asynchronous work directly in
|
|
/// > ``EffectPublisher/run(priority:operation:catch:fileID:line:)`` by adopting a non-Combine
|
|
/// > interface, or by iterating over the publisher's asynchronous sequence of `values`:
|
|
/// >
|
|
/// > ```swift
|
|
/// > return .run { send in
|
|
/// > for await value in publisher.values {
|
|
/// > send(.response(value))
|
|
/// > }
|
|
/// > }
|
|
/// > ```
|
|
///
|
|
/// - Parameter publisher: A publisher.
|
|
@available(
|
|
*,
|
|
deprecated,
|
|
message:
|
|
"Iterate over 'Publisher.values' in an 'Effect.run', instead, or use 'Effect.publisher'."
|
|
)
|
|
public init<P: Publisher>(_ publisher: P) where P.Output == Output, P.Failure == Failure {
|
|
self.operation = .publisher(publisher.eraseToAnyPublisher())
|
|
}
|
|
|
|
/// Initializes an effect that immediately emits the value passed in.
|
|
///
|
|
/// - Parameter value: The value that is immediately emitted by the effect.
|
|
@available(*, deprecated, message: "Use 'Effect.send', instead.")
|
|
public init(value: Action) {
|
|
self.init(Just(value).setFailureType(to: Failure.self))
|
|
}
|
|
|
|
/// Initializes an effect that immediately fails with the error passed in.
|
|
///
|
|
/// - Parameter error: The error that is immediately emitted by the effect.
|
|
@available(
|
|
*,
|
|
deprecated,
|
|
message: "Throw and catch errors directly in an 'Effect.run', instead."
|
|
)
|
|
public init(error: Failure) {
|
|
// NB: Ideally we'd return a `Fail` publisher here, but due to a bug in iOS 13 that publisher
|
|
// can crash when used with certain combinations of operators such as `.retry.catch`. The
|
|
// bug was fixed in iOS 14, but to remain compatible with iOS 13 and higher we need to do
|
|
// a little trickery to fail in a slightly different way.
|
|
self.init(
|
|
Deferred {
|
|
Future { $0(.failure(error)) }
|
|
}
|
|
)
|
|
}
|
|
|
|
/// Creates an effect that can supply a single value asynchronously in the future.
|
|
///
|
|
/// This can be helpful for converting APIs that are callback-based into ones that deal with
|
|
/// ``EffectPublisher``s.
|
|
///
|
|
/// For example, to create an effect that delivers an integer after waiting a second:
|
|
///
|
|
/// ```swift
|
|
/// EffectPublisher<Int, Never>.future { callback in
|
|
/// DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
/// callback(.success(42))
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// Note that you can only deliver a single value to the `callback`. If you send more they will be
|
|
/// discarded:
|
|
///
|
|
/// ```swift
|
|
/// EffectPublisher<Int, Never>.future { callback in
|
|
/// DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
/// callback(.success(42))
|
|
/// callback(.success(1729)) // Will not be emitted by the effect
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// If you need to deliver more than one value to the effect, you should use the
|
|
/// ``EffectPublisher`` initializer that accepts a ``Subscriber`` value.
|
|
///
|
|
/// - Parameter attemptToFulfill: A closure that takes a `callback` as an argument which can be
|
|
/// used to feed it `Result<Output, Failure>` values.
|
|
@available(*, deprecated, message: "Use 'Effect.run', instead.")
|
|
public static func future(
|
|
_ attemptToFulfill: @escaping (@escaping (Result<Action, Failure>) -> Void) -> Void
|
|
) -> Self {
|
|
withEscapedDependencies { escaped in
|
|
Deferred {
|
|
escaped.yield {
|
|
Future(attemptToFulfill)
|
|
}
|
|
}.eraseToEffect()
|
|
}
|
|
}
|
|
|
|
/// Initializes an effect that lazily executes some work in the real world and synchronously sends
|
|
/// that data back into the store.
|
|
///
|
|
/// For example, to load a user from some JSON on the disk, one can wrap that work in an effect:
|
|
///
|
|
/// ```swift
|
|
/// EffectPublisher<User, Error>.result {
|
|
/// let fileUrl = URL(
|
|
/// fileURLWithPath: NSSearchPathForDirectoriesInDomains(
|
|
/// .documentDirectory, .userDomainMask, true
|
|
/// )[0]
|
|
/// )
|
|
/// .appendingPathComponent("user.json")
|
|
///
|
|
/// let result = Result<User, Error> {
|
|
/// let data = try Data(contentsOf: fileUrl)
|
|
/// return try JSONDecoder().decode(User.self, from: $0)
|
|
/// }
|
|
///
|
|
/// return result
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// - Parameter attemptToFulfill: A closure encapsulating some work to execute in the real world.
|
|
/// - Returns: An effect.
|
|
@available(*, deprecated, message: "Use 'Effect.run', instead.")
|
|
public static func result(_ attemptToFulfill: @escaping () -> Result<Action, Failure>) -> Self {
|
|
.future { $0(attemptToFulfill()) }
|
|
}
|
|
|
|
/// Initializes an effect from a callback that can send as many values as it wants, and can send
|
|
/// a completion.
|
|
///
|
|
/// This initializer is useful for bridging callback APIs, delegate APIs, and manager APIs to the
|
|
/// ``EffectPublisher`` type. One can wrap those APIs in an Effect so that its events are sent
|
|
/// through the effect, which allows the reducer to handle them.
|
|
///
|
|
/// For example, one can create an effect to ask for access to `MPMediaLibrary`. It can start by
|
|
/// sending the current status immediately, and then if the current status is `notDetermined` it
|
|
/// can request authorization, and once a status is received it can send that back to the effect:
|
|
///
|
|
/// ```swift
|
|
/// EffectPublisher.run { subscriber in
|
|
/// subscriber.send(MPMediaLibrary.authorizationStatus())
|
|
///
|
|
/// guard MPMediaLibrary.authorizationStatus() == .notDetermined else {
|
|
/// subscriber.send(completion: .finished)
|
|
/// return AnyCancellable {}
|
|
/// }
|
|
///
|
|
/// MPMediaLibrary.requestAuthorization { status in
|
|
/// subscriber.send(status)
|
|
/// subscriber.send(completion: .finished)
|
|
/// }
|
|
/// return AnyCancellable {
|
|
/// // Typically clean up resources that were created here, but this effect doesn't
|
|
/// // have any.
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// - Parameter work: A closure that accepts a ``Subscriber`` value and returns a cancellable.
|
|
/// When the ``EffectPublisher`` is completed, the cancellable will be used to clean up any
|
|
/// resources created when the effect was started.
|
|
@available(*, deprecated, message: "Use the async version of 'Effect.run', instead.")
|
|
public static func run(
|
|
_ work: @escaping (EffectPublisher.Subscriber) -> Cancellable
|
|
) -> Self {
|
|
withEscapedDependencies { escaped in
|
|
AnyPublisher.create { subscriber in
|
|
escaped.yield {
|
|
work(subscriber)
|
|
}
|
|
}
|
|
.eraseToEffect()
|
|
}
|
|
}
|
|
|
|
/// Creates an effect that executes some work in the real world that doesn't need to feed data
|
|
/// back into the store. If an error is thrown, the effect will complete and the error will be
|
|
/// ignored.
|
|
///
|
|
/// - Parameter work: A closure encapsulating some work to execute in the real world.
|
|
/// - Returns: An effect.
|
|
@available(*, deprecated, message: "Use 'Effect.run { _ in … }', instead.")
|
|
public static func fireAndForget(_ work: @escaping () throws -> Void) -> Self {
|
|
// NB: Ideally we'd return a `Deferred` wrapping an `Empty(completeImmediately: true)`, but
|
|
// due to a bug in iOS 13.2 that publisher will never complete. The bug was fixed in
|
|
// iOS 13.3, but to remain compatible with iOS 13.2 and higher we need to do a little
|
|
// trickery to make sure the deferred publisher completes.
|
|
withEscapedDependencies { escaped in
|
|
Deferred { () -> Publishers.CompactMap<Result<Action?, Failure>.Publisher, Action> in
|
|
escaped.yield {
|
|
try? work()
|
|
}
|
|
return Just<Output?>(nil)
|
|
.setFailureType(to: Failure.self)
|
|
.compactMap { $0 }
|
|
}
|
|
.eraseToEffect()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension EffectPublisher where Failure == Error {
|
|
/// Initializes an effect that lazily executes some work in the real world and synchronously sends
|
|
/// that data back into the store.
|
|
///
|
|
/// For example, to load a user from some JSON on the disk, one can wrap that work in an effect:
|
|
///
|
|
/// ```swift
|
|
/// EffectPublisher<User, Error>.catching {
|
|
/// let fileUrl = URL(
|
|
/// fileURLWithPath: NSSearchPathForDirectoriesInDomains(
|
|
/// .documentDirectory, .userDomainMask, true
|
|
/// )[0]
|
|
/// )
|
|
/// .appendingPathComponent("user.json")
|
|
///
|
|
/// let data = try Data(contentsOf: fileUrl)
|
|
/// return try JSONDecoder().decode(User.self, from: $0)
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// - Parameter work: A closure encapsulating some work to execute in the real world.
|
|
/// - Returns: An effect.
|
|
@available(
|
|
*,
|
|
deprecated,
|
|
message: "Throw and catch errors directly in an 'Effect.run', instead."
|
|
)
|
|
public static func catching(_ work: @escaping () throws -> Action) -> Self {
|
|
.future { $0(Result { try work() }) }
|
|
}
|
|
}
|
|
|
|
extension Publisher {
|
|
/// Turns any publisher into an ``EffectPublisher``.
|
|
///
|
|
/// This can be useful for when you perform a chain of publisher transformations in a reducer, and
|
|
/// you need to convert that publisher to an effect so that you can return it from the reducer:
|
|
///
|
|
/// ```swift
|
|
/// case .buttonTapped:
|
|
/// return fetchUser(id: 1)
|
|
/// .filter(\.isAdmin)
|
|
/// .eraseToEffect()
|
|
/// ```
|
|
///
|
|
/// - Returns: An effect that wraps `self`.
|
|
@available(
|
|
*,
|
|
deprecated,
|
|
message:
|
|
"Iterate over 'Publisher.values' in an 'Effect.run', instead, or use 'Effect.publisher'."
|
|
)
|
|
public func eraseToEffect() -> EffectPublisher<Output, Failure> {
|
|
EffectPublisher(self)
|
|
}
|
|
|
|
/// Turns any publisher into an ``EffectPublisher``.
|
|
///
|
|
/// This is a convenience operator for writing ``EffectPublisher/eraseToEffect()`` followed by
|
|
/// ``EffectPublisher/map(_:)-28ghh`.
|
|
///
|
|
/// ```swift
|
|
/// case .buttonTapped:
|
|
/// return fetchUser(id: 1)
|
|
/// .filter(\.isAdmin)
|
|
/// .eraseToEffect(ProfileAction.adminUserFetched)
|
|
/// ```
|
|
///
|
|
/// - Parameters:
|
|
/// - transform: A mapping function that converts `Output` to another type.
|
|
/// - Returns: An effect that wraps `self` after mapping `Output` values.
|
|
@available(
|
|
*,
|
|
deprecated,
|
|
message:
|
|
"Iterate over 'Publisher.values' in an 'Effect.run', instead, or use 'Effect.publisher'."
|
|
)
|
|
public func eraseToEffect<T>(
|
|
_ transform: @escaping (Output) -> T
|
|
) -> EffectPublisher<T, Failure> {
|
|
self.map(
|
|
withEscapedDependencies { escaped in
|
|
{ action in
|
|
escaped.yield {
|
|
transform(action)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
.eraseToEffect()
|
|
}
|
|
|
|
/// Turns any publisher into an ``Effect`` that cannot fail by wrapping its output and failure
|
|
/// in a result.
|
|
///
|
|
/// This can be useful when you are working with a failing API but want to deliver its data to an
|
|
/// action that handles both success and failure.
|
|
///
|
|
/// ```swift
|
|
/// case .buttonTapped:
|
|
/// return self.apiClient.fetchUser(id: 1)
|
|
/// .catchToEffect()
|
|
/// .map(ProfileAction.userResponse)
|
|
/// ```
|
|
///
|
|
/// - Returns: An effect that wraps `self`.
|
|
@available(
|
|
*, deprecated,
|
|
message:
|
|
"Iterate over 'Publisher.values' in an 'Effect.run', instead, or use 'Effect.publisher'."
|
|
)
|
|
public func catchToEffect() -> Effect<Result<Output, Failure>> {
|
|
self.catchToEffect { $0 }
|
|
}
|
|
|
|
/// Turns any publisher into an ``Effect`` that cannot fail by wrapping its output and failure
|
|
/// into a result and then applying passed in function to it.
|
|
///
|
|
/// This is a convenience operator for writing ``EffectPublisher/eraseToEffect()`` followed by
|
|
/// ``EffectPublisher/map(_:)-28ghh`.
|
|
///
|
|
/// ```swift
|
|
/// case .buttonTapped:
|
|
/// return self.apiClient.fetchUser(id: 1)
|
|
/// .catchToEffect(ProfileAction.userResponse)
|
|
/// ```
|
|
///
|
|
/// - Parameters:
|
|
/// - transform: A mapping function that converts `Result<Output,Failure>` to another type.
|
|
/// - Returns: An effect that wraps `self`.
|
|
@available(
|
|
*,
|
|
deprecated,
|
|
message:
|
|
"Iterate over 'Publisher.values' in an 'Effect.run', instead, or use 'Effect.publisher'."
|
|
)
|
|
public func catchToEffect<T>(
|
|
_ transform: @escaping (Result<Output, Failure>) -> T
|
|
) -> Effect<T> {
|
|
return
|
|
self
|
|
.map(
|
|
withEscapedDependencies { escaped in
|
|
{ action in
|
|
escaped.yield {
|
|
transform(.success(action))
|
|
}
|
|
}
|
|
}
|
|
)
|
|
.catch { Just(transform(.failure($0))) }
|
|
.eraseToEffect()
|
|
}
|
|
|
|
/// Turns any publisher into an ``EffectPublisher`` for any output and failure type by ignoring
|
|
/// all output and any failure.
|
|
///
|
|
/// This is useful for times you want to fire off an effect but don't want to feed any data back
|
|
/// into the system. It can automatically promote an effect to your reducer's domain.
|
|
///
|
|
/// ```swift
|
|
/// case .buttonTapped:
|
|
/// return analyticsClient.track("Button Tapped")
|
|
/// .fireAndForget()
|
|
/// ```
|
|
///
|
|
/// - Parameters:
|
|
/// - outputType: An output type.
|
|
/// - failureType: A failure type.
|
|
/// - Returns: An effect that never produces output or errors.
|
|
@available(
|
|
*,
|
|
deprecated,
|
|
message: "Iterate over 'Publisher.values' in an 'Effect.run', instead, or use Effect.publisher"
|
|
)
|
|
public func fireAndForget<NewOutput, NewFailure>(
|
|
outputType: NewOutput.Type = NewOutput.self,
|
|
failureType: NewFailure.Type = NewFailure.self
|
|
) -> EffectPublisher<NewOutput, NewFailure> {
|
|
return
|
|
self
|
|
.flatMap { _ in Empty<NewOutput, Failure>() }
|
|
.catch { _ in Empty() }
|
|
.eraseToEffect()
|
|
}
|
|
}
|
|
|
|
@usableFromInline
|
|
internal struct EffectPublisherWrapper<Action, Failure: Error>: Publisher {
|
|
@usableFromInline typealias Output = Action
|
|
|
|
let effect: EffectPublisher<Action, Failure>
|
|
|
|
@usableFromInline
|
|
init(_ effect: EffectPublisher<Action, Failure>) {
|
|
self.effect = effect
|
|
}
|
|
|
|
@usableFromInline
|
|
func receive<S: Combine.Subscriber>(
|
|
subscriber: S
|
|
) where S.Input == Action, S.Failure == Failure {
|
|
self.publisher.subscribe(subscriber)
|
|
}
|
|
|
|
private var publisher: AnyPublisher<Action, Failure> {
|
|
switch self.effect.operation {
|
|
case .none:
|
|
return Empty().eraseToAnyPublisher()
|
|
case let .publisher(publisher):
|
|
return publisher
|
|
case let .run(priority, operation):
|
|
return .create { subscriber in
|
|
let task = Task(priority: priority) { @MainActor in
|
|
defer { subscriber.send(completion: .finished) }
|
|
let send = Send { subscriber.send($0) }
|
|
await operation(send)
|
|
}
|
|
return AnyCancellable {
|
|
task.cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Publisher {
|
|
@usableFromInline
|
|
func eraseToEffectPublisher() -> EffectPublisher<Output, Failure> {
|
|
EffectPublisher(operation: .publisher(self.eraseToAnyPublisher()))
|
|
}
|
|
}
|