import Combine import Foundation extension Effect { /// Turns an effect into one that is capable of being canceled. /// /// To turn an effect into a cancellable one you must provide an identifier, which is used in /// ``Effect/cancel(id:)`` to identify which in-flight effect should be canceled. /// Any hashable value can be used for the identifier, such as a string, but you can add a bit of /// protection against typos by defining a new type for the identifier: /// /// ```swift /// enum CancelID { case loadUser } /// /// case .reloadButtonTapped: /// // Start a new effect to load the user /// return .run { send in /// await send( /// .userResponse( /// TaskResult { try await self.apiClient.loadUser() } /// ) /// ) /// } /// .cancellable(id: CancelID.loadUser, cancelInFlight: true) /// /// case .cancelButtonTapped: /// // Cancel any in-flight requests to load the user /// return .cancel(id: CancelID.loadUser) /// ``` /// /// - Parameters: /// - id: The effect's identifier. /// - cancelInFlight: Determines if any in-flight effect with the same identifier should be /// canceled before starting this new one. /// - Returns: A new effect that is capable of being canceled by an identifier. public func cancellable(id: ID, cancelInFlight: Bool = false) -> Self { @Dependency(\.navigationIDPath) var navigationIDPath switch self.operation { case .none: return .none case let .publisher(publisher): return Self( operation: .publisher( Deferred { () -> Publishers.HandleEvents< Publishers.PrefixUntilOutput< AnyPublisher, PassthroughSubject > > in _cancellablesLock.lock() defer { _cancellablesLock.unlock() } if cancelInFlight { _cancellationCancellables.cancel(id: id, path: navigationIDPath) } let cancellationSubject = PassthroughSubject() var cancellable: AnyCancellable! cancellable = AnyCancellable { _cancellablesLock.sync { cancellationSubject.send(()) cancellationSubject.send(completion: .finished) _cancellationCancellables.remove(cancellable, at: id, path: navigationIDPath) } } return publisher.prefix(untilOutputFrom: cancellationSubject) .handleEvents( receiveSubscription: { _ in _cancellablesLock.sync { _cancellationCancellables.insert(cancellable, at: id, path: navigationIDPath) } }, receiveCompletion: { _ in cancellable.cancel() }, receiveCancel: cancellable.cancel ) } .eraseToAnyPublisher() ) ) case let .run(priority, operation): return withEscapedDependencies { continuation in return Self( operation: .run(priority) { send in await continuation.yield { await withTaskCancellation(id: id, cancelInFlight: cancelInFlight) { await operation(send) } } } ) } } } /// An effect that will cancel any currently in-flight effect with the given identifier. /// /// - Parameter id: An effect identifier. /// - Returns: A new effect that will cancel any currently in-flight effect with the given /// identifier. public static func cancel(id: ID) -> Self { let dependencies = DependencyValues._current @Dependency(\.navigationIDPath) var navigationIDPath // 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. return .publisher { () -> Publishers.CompactMap, Action> in DependencyValues.$_current.withValue(dependencies) { _cancellablesLock.sync { _cancellationCancellables.cancel(id: id, path: navigationIDPath) } } return Just(nil).compactMap { $0 } } } } /// Execute an operation with a cancellation identifier. /// /// If the operation is in-flight when `Task.cancel(id:)` is called with the same identifier, the /// operation will be cancelled. /// /// ``` /// enum CancelID { case timer } /// /// await withTaskCancellation(id: CancelID.timer) { /// // Start cancellable timer... /// } /// ``` /// /// ### Debouncing tasks /// /// When paired with a clock, this function can be used to debounce a unit of async work by /// specifying the `cancelInFlight`, which will automatically cancel any in-flight work with the /// same identifier: /// /// ```swift /// @Dependency(\.continuousClock) var clock /// enum CancelID { case response } /// /// // ... /// /// return .run { send in /// try await withTaskCancellation(id: CancelID.response, cancelInFlight: true) { /// try await self.clock.sleep(for: .seconds(0.3)) /// await send( /// .debouncedResponse(TaskResult { try await environment.request() }) /// ) /// } /// } /// ``` /// /// - Parameters: /// - id: A unique identifier for the operation. /// - cancelInFlight: Determines if any in-flight operation with the same identifier should be /// canceled before starting this new one. /// - operation: An async operation. /// - Throws: An error thrown by the operation. /// - Returns: A value produced by operation. @_unsafeInheritExecutor public func withTaskCancellation( id: ID, cancelInFlight: Bool = false, operation: @Sendable @escaping () async throws -> T ) async rethrows -> T { @Dependency(\.navigationIDPath) var navigationIDPath let (cancellable, task) = _cancellablesLock.sync { () -> (AnyCancellable, Task) in if cancelInFlight { _cancellationCancellables.cancel(id: id, path: navigationIDPath) } let task = Task { try await operation() } let cancellable = AnyCancellable { task.cancel() } _cancellationCancellables.insert(cancellable, at: id, path: navigationIDPath) return (cancellable, task) } defer { _cancellablesLock.sync { _cancellationCancellables.remove(cancellable, at: id, path: navigationIDPath) } } do { return try await task.cancellableValue } catch { return try Result.failure(error)._rethrowGet() } } extension Task where Success == Never, Failure == Never { /// Cancel any currently in-flight operation with the given identifier. /// /// - Parameter id: An identifier. public static func cancel(id: ID) { @Dependency(\.navigationIDPath) var navigationIDPath return _cancellablesLock.sync { _cancellationCancellables.cancel(id: id, path: navigationIDPath) } } } @_spi(Internals) public struct _CancelID: Hashable { let discriminator: ObjectIdentifier let id: AnyHashable let navigationIDPath: NavigationIDPath init(id: ID, navigationIDPath: NavigationIDPath) { self.discriminator = ObjectIdentifier(type(of: id)) self.id = id self.navigationIDPath = navigationIDPath } } @_spi(Internals) public var _cancellationCancellables = CancellablesCollection() private let _cancellablesLock = NSRecursiveLock() @rethrows private protocol _ErrorMechanism { associatedtype Output func get() throws -> Output } extension _ErrorMechanism { func _rethrowError() rethrows -> Never { _ = try _rethrowGet() fatalError() } func _rethrowGet() rethrows -> Output { return try get() } } extension Result: _ErrorMechanism {} @_spi(Internals) public class CancellablesCollection { var storage: [_CancelID: Set] = [:] func insert( _ cancellable: AnyCancellable, at id: ID, path: NavigationIDPath ) { for navigationIDPath in path.prefixes { let cancelID = _CancelID(id: id, navigationIDPath: navigationIDPath) self.storage[cancelID, default: []].insert(cancellable) } } func remove( _ cancellable: AnyCancellable, at id: ID, path: NavigationIDPath ) { for navigationIDPath in path.prefixes { let cancelID = _CancelID(id: id, navigationIDPath: navigationIDPath) self.storage[cancelID]?.remove(cancellable) if self.storage[cancelID]?.isEmpty == true { self.storage[cancelID] = nil } } } func cancel( id: ID, path: NavigationIDPath ) { let cancelID = _CancelID(id: id, navigationIDPath: path) self.storage[cancelID]?.forEach { $0.cancel() } self.storage[cancelID] = nil } func exists( at id: ID, path: NavigationIDPath ) -> Bool { return self.storage[_CancelID(id: id, navigationIDPath: path)] != nil } public var count: Int { return self.storage.count } public func removeAll() { self.storage.removeAll() } }