diff --git a/Examples/README.md b/Examples/README.md index 0ea77f7d4989..ec91852f767e 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -15,7 +15,7 @@ This directory holds many case studies and applications to demonstrate solving v
This application is a faithful reconstruction of one of Apple's more interesting sample projects, called [Scrumdinger][scrumdinger]. It deals with many forms of navigation (alerts, sheets, drill-downs) and many forms of side effects (data persistence, timers and speech recognizers). * **Tic-Tac-Toe** -
Builds a moderately complex application in both SwiftUI and UIKit that is fully controlled by the Composable Architecture. The core application logic is put into its own modules, with no UI, and then both of the SwiftUI and UIKit applications are run off of that single source of logic. This demonstrates how one can hyper-modularize an application, which for a big enough application can greatly help compile times and developer productivity. This demo was inspired by the equivalent demos in [RIBs](http://github.com/uber/RIBs) (see [here](https://github.com/uber/RIBs/tree/master/ios/tutorials/tutorial4-completed)) and [Workflow](https://github.com/square/workflow/) (see [here](https://github.com/square/workflow-swift/tree/main/Samples/TicTacToe)). +
Builds a moderately complex application in both SwiftUI and UIKit that is fully controlled by the Composable Architecture. The core application logic is put into its own modules, with no UI, and then both of the SwiftUI and UIKit applications are run off of that single source of logic. This demonstrates how one can hyper-modularize an application, which for a big enough application can greatly help compile times and developer productivity. This demo was inspired by the equivalent demos in [RIBs](https://github.com/uber/RIBs-iOS) (see [here](https://github.com/uber/RIBs-iOS/tree/main/tutorials/tutorial4-completed)) and [Workflow](https://github.com/square/workflow/) (see [here](https://github.com/square/workflow-swift/tree/main/Samples/TicTacToe)). * **Todos**
A simple todo application with a few bells and whistles, and a comprehensive test suite. diff --git a/Sources/ComposableArchitecture/Core.swift b/Sources/ComposableArchitecture/Core.swift index b50119322fb0..5a2c876db524 100644 --- a/Sources/ComposableArchitecture/Core.swift +++ b/Sources/ComposableArchitecture/Core.swift @@ -136,9 +136,9 @@ final class RootCore: Core { task.cancel() } } - case let .run(priority, operation): + case let .run(name, priority, operation): withEscapedDependencies { continuation in - let task = Task(priority: priority) { @MainActor [weak self] in + let task = Task(name: name, priority: priority) { @MainActor [weak self] in let isCompleted = LockIsolated(false) defer { isCompleted.setValue(true) } await operation( diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index 965565d50a14..505bd797e3f4 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -7,7 +7,11 @@ public struct Effect: Sendable { enum Operation: Sendable { case none case publisher(AnyPublisher) - case run(TaskPriority? = nil, @Sendable (_ send: Send) async -> Void) + case run( + name: String? = nil, + priority: TaskPriority? = nil, + operation: @Sendable (_ send: Send) async -> Void + ) } @usableFromInline @@ -76,6 +80,7 @@ extension Effect { /// - Parameters: /// - priority: Priority of the underlying task. If `nil`, the priority will come from /// `Task.currentPriority`. + /// - name: An optional name to associate with the task that runs this effect. /// - operation: The operation to execute. /// - handler: An error handler, invoked if the operation throws an error other than /// `CancellationError`. @@ -86,6 +91,7 @@ extension Effect { /// - Returns: An effect wrapping the given asynchronous work. public static func run( priority: TaskPriority? = nil, + name: String? = nil, operation: @escaping @Sendable (_ send: Send) async throws -> Void, catch handler: (@Sendable (_ error: any Error, _ send: Send) async -> Void)? = nil, fileID: StaticString = #fileID, @@ -95,7 +101,7 @@ extension Effect { ) -> Self { withEscapedDependencies { escaped in Self( - operation: .run(priority) { send in + operation: .run(name: name, priority: priority) { send in await escaped.yield { do { try await operation(send) @@ -268,14 +274,17 @@ extension Effect { .eraseToAnyPublisher() ) ) - case let (.run(lhsPriority, lhsOperation), .run(rhsPriority, rhsOperation)): + case ( + .run(let lhsName, let lhsPriority, let lhsOperation), + .run(let rhsName, let rhsPriority, let rhsOperation) + ): return Self( operation: .run { send in await withTaskGroup(of: Void.self) { group in - group.addTask(priority: lhsPriority) { + group.addTask(name: lhsName, priority: lhsPriority) { await lhsOperation(send) } - group.addTask(priority: rhsPriority) { + group.addTask(name: rhsName, priority: rhsPriority) { await rhsOperation(send) } } @@ -328,16 +337,21 @@ extension Effect { .eraseToAnyPublisher() ) ) - case let (.run(lhsPriority, lhsOperation), .run(rhsPriority, rhsOperation)): + case ( + .run(let lhsName, let lhsPriority, let lhsOperation), + .run(let rhsName, let rhsPriority, let rhsOperation) + ): return Self( operation: .run { send in if let lhsPriority { - await Task(priority: lhsPriority) { await lhsOperation(send) }.cancellableValue + await Task(name: lhsName, priority: lhsPriority) { await lhsOperation(send) } + .cancellableValue } else { await lhsOperation(send) } if let rhsPriority { - await Task(priority: rhsPriority) { await rhsOperation(send) }.cancellableValue + await Task(name: rhsName, priority: rhsPriority) { await rhsOperation(send) } + .cancellableValue } else { await rhsOperation(send) } @@ -356,7 +370,7 @@ extension Effect { switch self.operation { case .none: return .none - case let .publisher(publisher): + case .publisher(let publisher): return .init( operation: .publisher( publisher @@ -372,10 +386,10 @@ extension Effect { .eraseToAnyPublisher() ) ) - case let .run(priority, operation): + case .run(let name, let priority, let operation): return withEscapedDependencies { escaped in .init( - operation: .run(priority) { send in + operation: .run(name: name, priority: priority) { send in await escaped.yield { await operation( Send { action in @@ -389,3 +403,39 @@ extension Effect { } } } + +#if swift(<6.2) + // NB: Backwards-compatible shims. + extension Task { + @discardableResult + @usableFromInline + init( + name: String?, + priority: TaskPriority? = nil, + operation: @escaping @Sendable () async -> Success + ) where Failure == Never { + self.init(priority: priority, operation: operation) + } + + @discardableResult + @usableFromInline + init( + name: String?, + priority: TaskPriority? = nil, + operation: @escaping @Sendable () async throws -> Success + ) where Failure == Error { + self.init(priority: priority, operation: operation) + } + } + + extension TaskGroup { + @usableFromInline + mutating func addTask( + name: String?, + priority: TaskPriority? = nil, + operation: @escaping @Sendable () async -> ChildTaskResult + ) { + addTask(priority: priority, operation: operation) + } + } +#endif diff --git a/Sources/ComposableArchitecture/Effects/Animation.swift b/Sources/ComposableArchitecture/Effects/Animation.swift index 7b46866dead4..6bb4c21b6ebf 100644 --- a/Sources/ComposableArchitecture/Effects/Animation.swift +++ b/Sources/ComposableArchitecture/Effects/Animation.swift @@ -42,10 +42,10 @@ extension Effect { TransactionPublisher(upstream: publisher, transaction: transaction).eraseToAnyPublisher() ) ) - case let .run(priority, operation): + case let .run(name, priority, operation): let uncheckedTransaction = UncheckedSendable(transaction) return Self( - operation: .run(priority) { send in + operation: .run(name: name, priority: priority) { send in await operation( Send { value in withTransaction(uncheckedTransaction.value) { diff --git a/Sources/ComposableArchitecture/Effects/Cancellation.swift b/Sources/ComposableArchitecture/Effects/Cancellation.swift index 7f075948ff52..a6ef9552a1fa 100644 --- a/Sources/ComposableArchitecture/Effects/Cancellation.swift +++ b/Sources/ComposableArchitecture/Effects/Cancellation.swift @@ -83,10 +83,10 @@ extension Effect { .eraseToAnyPublisher() ) ) - case let .run(priority, operation): + case let .run(name, priority, operation): return withEscapedDependencies { continuation in return Self( - operation: .run(priority) { send in + operation: .run(name: name, priority: priority) { send in await continuation.yield { await withTaskCancellation(id: id, cancelInFlight: cancelInFlight) { await operation(send) diff --git a/Sources/ComposableArchitecture/Effects/Publisher.swift b/Sources/ComposableArchitecture/Effects/Publisher.swift index 4093d31c9d11..8fb9b7a661e7 100644 --- a/Sources/ComposableArchitecture/Effects/Publisher.swift +++ b/Sources/ComposableArchitecture/Effects/Publisher.swift @@ -30,9 +30,9 @@ public struct _EffectPublisher: Publisher { return Empty().eraseToAnyPublisher() case let .publisher(publisher): return publisher - case let .run(priority, operation): + case let .run(name, priority, operation): return .create { subscriber in - let task = Task(priority: priority) { @MainActor in + let task = Task(name: name, priority: priority) { @MainActor in defer { subscriber.send(completion: .finished) } await operation(Send { subscriber.send($0) }) } diff --git a/Sources/ComposableArchitecture/Internal/EffectActions.swift b/Sources/ComposableArchitecture/Internal/EffectActions.swift index 0dc15cad4f2c..338be97c11e6 100644 --- a/Sources/ComposableArchitecture/Internal/EffectActions.swift +++ b/Sources/ComposableArchitecture/Internal/EffectActions.swift @@ -15,9 +15,9 @@ extension Effect where Action: Sendable { cancellable.cancel() } } - case let .run(priority, operation): + case let .run(name, priority, operation): return AsyncStream { continuation in - let task = Task(priority: priority) { + let task = Task(name: name, priority: priority) { await operation(Send { action in continuation.yield(action) }) continuation.finish() } diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/SignpostReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/SignpostReducer.swift index aa32beaed479..01769f025e30 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/SignpostReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/SignpostReducer.swift @@ -114,9 +114,9 @@ extension Effect { .eraseToAnyPublisher() ) ) - case let .run(priority, operation): + case let .run(name, priority, operation): return .init( - operation: .run(priority) { send in + operation: .run(name: name, priority: priority) { send in os_signpost( .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, actionOutput diff --git a/Tests/ComposableArchitectureTests/EffectOperationTests.swift b/Tests/ComposableArchitectureTests/EffectOperationTests.swift index 7936a1d32b5c..0863474cb422 100644 --- a/Tests/ComposableArchitectureTests/EffectOperationTests.swift +++ b/Tests/ComposableArchitectureTests/EffectOperationTests.swift @@ -17,7 +17,7 @@ effect = Effect.run { send in await send(42) } .merge(with: .none) switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init(send: { XCTAssertEqual($0, 42) })) default: XCTFail() @@ -26,7 +26,7 @@ effect = Effect.none .merge(with: .run { send in await send(42) }) switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init(send: { XCTAssertEqual($0, 42) })) default: XCTFail() @@ -35,7 +35,7 @@ effect = Effect.run { await $0(42) } .merge(with: .none) switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init(send: { XCTAssertEqual($0, 42) })) default: XCTFail() @@ -44,7 +44,7 @@ effect = Effect.none .merge(with: .run { await $0(42) }) switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init(send: { XCTAssertEqual($0, 42) })) default: XCTFail() @@ -64,7 +64,7 @@ effect = Effect.run { send in await send(42) } .concatenate(with: .none) switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init(send: { XCTAssertEqual($0, 42) })) default: XCTFail() @@ -73,7 +73,7 @@ effect = Effect.none .concatenate(with: .run { send in await send(42) }) switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init(send: { XCTAssertEqual($0, 42) })) default: XCTFail() @@ -82,7 +82,7 @@ effect = Effect.run { send in await send(42) } .concatenate(with: .none) switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init(send: { XCTAssertEqual($0, 42) })) default: XCTFail() @@ -91,7 +91,7 @@ effect = Effect.none .concatenate(with: .run { send in await send(42) }) switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init(send: { XCTAssertEqual($0, 42) })) default: XCTFail() @@ -113,7 +113,7 @@ } ) switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init { values.append($0) }) default: XCTFail() @@ -129,7 +129,7 @@ let effect = Effect.run { send in await send(42) } .concatenate(with: .run { send in await send(1729) }) switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init(send: { values.append($0) })) default: XCTFail() @@ -143,7 +143,7 @@ .map { "\($0)" } switch effect.operation { - case let .run(_, send): + case let .run(_, _, send): await send(.init(send: { XCTAssertEqual($0, "42") })) default: XCTFail()