From 8e2796c24525f27f6c1794a7d425d5261cf70259 Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Sat, 6 Jun 2026 13:13:30 +0200 Subject: [PATCH] feat: Refactor Effect Factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary • Switch docs and examples to dot-prefixed factories: .task, .cancel, .sequence, .action, .send, .observe. • Update SwiftUI snippets to wrap modifiers on a view (e.g., ContentView().refreshable { ... }). • Move observe helpers to TransducerEffect namespace and pass env to handlers. Documentation changes • ArchitecturalComparison, CorrectByConstruction, Recipes, TamingAsyncTasksInSwiftUIViews, UsingEnvForDependencyInjection, SwiftUIFirst: • run → .task; cancel/sequence → .cancel/.sequence; fix signatures to -> Effect?. • Replace post with input(...); add concise Button/onChange shortcut. • Wrap .refreshable/.task(id:) in ContentView(). Examples/tests changes • Examples (Counter, Movies, RemoteCounter): use .task/.cancel/.sequence and .observe; minor stylistic consistency. • Tests (EffectViewTests, EffectViewInputTests, EffectObservableTests, EffectActorTests, TaskSubscriptionTests, AsyncActionRuntimeTests): • Replace task(...) with .task(...), sequence([...]) with .sequence([...]), cancel with .cancel. • Use .action and .send where appropriate; update observe to .observe with (input, value, env). --- Documentation/ArchitecturalComparison.md | 2 +- .../BridgingEventDrivenAndImperative.md | 45 ++--- Documentation/CorrectByConstruction.md | 8 +- Documentation/Recipes.md | 34 ++-- Documentation/SwiftUIFirst.md | 2 +- .../TamingAsyncTasksInSwiftUIViews.md | 9 +- .../UsingEnvForDependencyInjection.md | 8 +- .../EffectViewExample/Counter.swift | 4 +- .../EffectViewExample/Movies.swift | 8 +- .../EffectViewExample/RemoteCounter.swift | 10 +- README.md | 2 +- ...e.swift => TransducerEffect.observe.swift} | 20 +-- ...swift => TransducerEffect.factories.swift} | 167 ++---------------- .../Transducer/TransducerEffect.swift | 3 +- .../AsyncActionRuntimeTests.swift | 8 +- Tests/EffectComponents/EffectActorTests.swift | 14 +- .../EffectObservableTests.swift | 6 +- .../EffectViewInputTests.swift | 4 +- Tests/EffectComponents/EffectViewTests.swift | 28 +-- .../TaskSubscriptionTests.swift | 24 +-- 20 files changed, 146 insertions(+), 260 deletions(-) rename Sources/EffectComponents/EffectObservable/{Transducer.observe.swift => TransducerEffect.observe.swift} (97%) rename Sources/EffectComponents/Transducer/{Transducer.Effects.swift => TransducerEffect.factories.swift} (76%) diff --git a/Documentation/ArchitecturalComparison.md b/Documentation/ArchitecturalComparison.md index fbfeb34..6255a0c 100644 --- a/Documentation/ArchitecturalComparison.md +++ b/Documentation/ArchitecturalComparison.md @@ -135,7 +135,7 @@ A task is automatically cancelled and replaced if `update` returns new work with // update: case .queryChanged(let q): state.query = q - return run(id: "search") { input, env in + return .task(id: "search") { input, env in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } let results = await env.search(q) diff --git a/Documentation/BridgingEventDrivenAndImperative.md b/Documentation/BridgingEventDrivenAndImperative.md index 338e71a..f921dce 100644 --- a/Documentation/BridgingEventDrivenAndImperative.md +++ b/Documentation/BridgingEventDrivenAndImperative.md @@ -41,11 +41,12 @@ has settled and returns an optional `Output` value, or throws if the runtime cannot accept or complete the request: ```swift -.refreshable { - try? await input.request(.refresh) - // resumes only when .refresh has been - // fully processed and the effect settled -} +ContentView() + .refreshable { + try? await input.request(.refresh) + // resumes only when .refresh has been + // fully processed and the effect settled + } ``` The spinner stays active for exactly as long as the work takes — no polling, @@ -82,9 +83,10 @@ consequence of that event. ### Pull-to-refresh ```swift -.refreshable { - try? await input.request(.refresh) -} +ContentView() + .refreshable { + try? await input.request(.refresh) + } ``` Note: in this case it is safe to write `try?` since we can ignore the error when @@ -117,11 +119,12 @@ When the app regains foreground, re-fetch only if the previous task has settled: ```swift -task(id: appPhase) { - if appPhase == .active { - try? await input.request(.resumeIfNeeded) +ContentView() + .task(id: appPhase) { + if appPhase == .active { + try? await input.request(.resumeIfNeeded) + } } -} ``` ### Testing @@ -156,9 +159,10 @@ launched by that action complete. ```swift // TCA -.refreshable { - await store.send(.refresh).finish() -} +ContentView() + .refreshable { + await store.send(.refresh).finish() + } ``` This works well for `.refreshable` and handles common edge cases correctly. @@ -194,11 +198,12 @@ As a result, bridging to `.refreshable` requires a workaround: a state flag ```swift // ImmutableData — workaround required -.refreshable { - try? dispatcher.dispatch(action: .refresh) - // must poll or observe isRefreshing to know - // when to let the closure return -} +ContentView() + .refreshable { + try? dispatcher.dispatch(action: .refresh) + // must poll or observe isRefreshing to know + // when to let the closure return + } ``` This is the class of problem that prompted the "event-driven doesn't work diff --git a/Documentation/CorrectByConstruction.md b/Documentation/CorrectByConstruction.md index cb77d26..fccc107 100644 --- a/Documentation/CorrectByConstruction.md +++ b/Documentation/CorrectByConstruction.md @@ -96,7 +96,7 @@ static func update( case (_, .searchTapped(let query)): state = .loading(query: query) - return sequence([ + return .sequence([ cancel("search"), run(id: "search") { input, env in do { @@ -118,7 +118,7 @@ static func update( case (.loading, .cancelTapped): state = .idle - return cancel("search") + return .cancel("search") default: return nil // event not valid in current state — ignore it @@ -163,7 +163,7 @@ Feature: Movie search // Scenario: User starts a search case (_, .searchTapped(let query)): state = .loading(query: query) - return run(id: "search") { ... } + return .task(id: "search") { ... } // Scenario: Search returns results case (.loading(let query), .resultsReceived(let movies)): @@ -173,7 +173,7 @@ case (.loading(let query), .resultsReceived(let movies)): // Scenario: User cancels while loading case (.loading, .cancelTapped): state = .idle - return cancel("search") + return .cancel("search") ``` The scenarios *are* the implementation. The mapping is near 1:1. diff --git a/Documentation/Recipes.md b/Documentation/Recipes.md index 942e3b7..59b927e 100644 --- a/Documentation/Recipes.md +++ b/Documentation/Recipes.md @@ -16,6 +16,18 @@ Button("Retry") { } ``` +Use a short cut. The following is equivalent to the above: +```swift +Button("Retry") { + try? input(.retryTapped) +} + +.onChange(of: query) { + try? input(.queryChanged($0)) +} +``` + + Note: in this case it is safe to write `try?` since we can ignore the error when attempting to dispatch an event when it happens within the button actions or within the onChange closure. @@ -84,15 +96,15 @@ case .queryChanged(let query): state.query = query state.isLoading = true - return run(id: "search") { input, env in + return .task(id: "search") { input, env in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } do { let results = try await env.search(query) - try input.post(.resultsLoaded(results)) + try input(.resultsLoaded(results)) } catch { - try input.post(.searchFailed(error.localizedDescription)) + try input(.searchFailed(error.localizedDescription)) } } ``` @@ -106,14 +118,14 @@ Use `sequence` when one step should happen before another. ```swift case .refresh: state.isRefreshing = true - return sequence([ - cancel("load"), - run(id: "refresh") { input, env in + return .sequence([ + .cancel("load"), + .task(id: "refresh") { input, env in do { let items = try await env.loadItems() - try input.post(.loaded(items)) + try input(.loaded(items)) } catch { - try input.post(.loadFailed(error.localizedDescription)) + try input(.loadFailed(error.localizedDescription)) } } ]) @@ -131,7 +143,7 @@ struct Env: Sendable { } case .startObserving: - return observe(\.store, keyPath: \.count) { input, count in + return .observe(\.store, keyPath: \.count) { input, count, env in try await input.request(.countChanged(count)) } @@ -211,7 +223,7 @@ enum SearchFeature: Transducer { switch (state, event) { case (.start, .start): state = .initializing - return task { _, env in + return .task { _, env in .didInitialize(try await env.makeClient()) } @@ -220,7 +232,7 @@ enum SearchFeature: Transducer { return nil case (.idle(let client), .refresh(let query)): - return task { _, _ in + return .task { _, _ in try await client.search(query) } diff --git a/Documentation/SwiftUIFirst.md b/Documentation/SwiftUIFirst.md index 735ec9b..430ff42 100644 --- a/Documentation/SwiftUIFirst.md +++ b/Documentation/SwiftUIFirst.md @@ -40,7 +40,7 @@ Logic lives in the transducer's transition function: static func update( _ state: inout State, event: Event -) -> Self.Effect? +) -> Effect? ``` This function is not an object. It has no stored properties, no lifecycle, and no hidden shared state. It is easier to read, easier to test, and easier to trace than a ViewModel — and it does more, because it explicitly models every side effect as a return value rather than as a fire-and-forget call inside an async method. diff --git a/Documentation/TamingAsyncTasksInSwiftUIViews.md b/Documentation/TamingAsyncTasksInSwiftUIViews.md index a8fa65b..9a85b96 100644 --- a/Documentation/TamingAsyncTasksInSwiftUIViews.md +++ b/Documentation/TamingAsyncTasksInSwiftUIViews.md @@ -64,7 +64,8 @@ This means a task can be cancelled because a parent view re-rendered and changed A common pattern for debounced search is: ```swift -task(id: query) { +ContentView() +.task(id: query) { try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } results = await search(query) @@ -130,7 +131,7 @@ List(state.items, id: \.self) { Text($0) } ```swift case .refresh: - return task(id: "refresh") { input, env in + return .task(id: "refresh") { input, env in do { let items = try await env.fetch() return try await input.request(.loaded(items)) @@ -169,7 +170,7 @@ Because task identifiers can be created at runtime, you can start as many tasks ```swift case .startDownload(let id): - return run(id: "download-\(id)") { input, env in + return .task(id: "download-\(id)") { input, env in do { let data = try await env.download(id) try input.post(.downloaded(id, data)) @@ -193,7 +194,7 @@ Starting a task whose identifier is already running cancels the previous run fir ```swift case .queryChanged(let q): state.query = q - return run(id: "search") { input, env in + return .task(id: "search") { input, env in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } let results = await env.search(q) diff --git a/Documentation/UsingEnvForDependencyInjection.md b/Documentation/UsingEnvForDependencyInjection.md index 99e3f94..a10053c 100644 --- a/Documentation/UsingEnvForDependencyInjection.md +++ b/Documentation/UsingEnvForDependencyInjection.md @@ -70,7 +70,7 @@ The transducer's `update` requirement now becomes: static func update( _ state: inout MovieSearchState, event: MovieSearchEvent -) -> Self.Effect? +) -> Effect? ``` And inside a task, `env` is simply the injected value: @@ -78,9 +78,9 @@ And inside a task, `env` is simply the injected value: ```swift case (_, .searchTapped(let query)): state = .loading(query: query) - return sequence([ - cancel("search"), - run(id: "search") { input, env in + return .sequence([ + .cancel("search"), + .task(id: "search") { input, env in env.trackQuery(query) do { let movies = try await env.search(query) diff --git a/Examples/EffectViewExample/EffectViewExample/Counter.swift b/Examples/EffectViewExample/EffectViewExample/Counter.swift index 73ece66..9346f81 100644 --- a/Examples/EffectViewExample/EffectViewExample/Counter.swift +++ b/Examples/EffectViewExample/EffectViewExample/Counter.swift @@ -40,7 +40,7 @@ extension Counter.Transducer: Transducer { switch event { case .start: state.counter = 0 - return task(id: "Counter") { input, env in + return .task(id: "Counter") { input, env in while true { do { try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec @@ -52,7 +52,7 @@ extension Counter.Transducer: Transducer { case .tick: state.counter += 1; return nil case .stop: - return cancel("Counter") + return .cancel("Counter") } } diff --git a/Examples/EffectViewExample/EffectViewExample/Movies.swift b/Examples/EffectViewExample/EffectViewExample/Movies.swift index baaead9..4816ab1 100644 --- a/Examples/EffectViewExample/EffectViewExample/Movies.swift +++ b/Examples/EffectViewExample/EffectViewExample/Movies.swift @@ -110,7 +110,7 @@ extension Movies.Transducer: Transducer { case .refresh: // Always supersedes a pending load; named task cancels any prior refresh. state.mode = .refreshing - return sequence([cancel("load"), refreshMovies()]) + return .sequence([.cancel("load"), refreshMovies()]) case .loaded(let movies): state.mode = .idle @@ -123,7 +123,7 @@ extension Movies.Transducer: Transducer { case .cancel: state.mode = .idle - return cancel("load") + return .cancel("load") case .dismiss: state.mode = .idle @@ -133,7 +133,7 @@ extension Movies.Transducer: Transducer { static func loadMovies() -> Effect { - task(id: "load") { input, env in + Effect.task(id: "load") { input, env in do { let movies = try await env.movieFetch() try input(.loaded(movies)) @@ -145,7 +145,7 @@ extension Movies.Transducer: Transducer { static func refreshMovies() -> Effect { // Note: a refresh action - task(id: "refresh") { input, env in + Effect.task(id: "refresh") { input, env in do { let movies = try await env.movieFetch() try input(.loaded(movies)) diff --git a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift index 9a3174e..e5f3498 100644 --- a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift +++ b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift @@ -74,10 +74,10 @@ extension RemoteCounter.Transducer: Transducer { switch event { case .start: - return observe( + return .observe( \.store, keyPath: \.count, id: "observe-store-count" - ) { input, value in + ) { input, value, env in print("observation-handler store.count: ", value) try? await input.request(.storeChanged(newCount: value)) } @@ -92,19 +92,19 @@ extension RemoteCounter.Transducer: Transducer { case .incrementTapped: print("incrementTapped") - return task { input, env in + return .task { input, env in await env.store.send(.increment) } case .decrementTapped: print("decrementTapped") - return task { _, env in + return .task { _, env in await env.store.send(.decrement) } case .resetTapped: print("resetTapped") - return task { _, env in + return .task { _, env in await env.store.send(.reset) } } diff --git a/README.md b/README.md index 21cb51f..237569c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ enum SearchFeature: Transducer { state.isLoading = true state.errorMessage = nil - return task(id: "search") { input, env in + return .task(id: "search") { input, env in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } diff --git a/Sources/EffectComponents/EffectObservable/Transducer.observe.swift b/Sources/EffectComponents/EffectObservable/TransducerEffect.observe.swift similarity index 97% rename from Sources/EffectComponents/EffectObservable/Transducer.observe.swift rename to Sources/EffectComponents/EffectObservable/TransducerEffect.observe.swift index ef6feeb..bbac4cc 100644 --- a/Sources/EffectComponents/EffectObservable/Transducer.observe.swift +++ b/Sources/EffectComponents/EffectObservable/TransducerEffect.observe.swift @@ -2,9 +2,9 @@ import Foundation import Mutex import Observation -// MARK: - Transducer.observe +// MARK: - TransducerEffect.observe -extension Transducer where Effect == TransducerEffect { +extension TransducerEffect { /// Observes a key path on an `@Observable` object resolved from the environment. /// @@ -44,12 +44,12 @@ extension Transducer where Effect == TransducerEffect { id: TaskIdentifier? = "observe", priority: TaskPriority? = nil, handler: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Value, Env) async -> Void - ) -> Effect + ) -> Self where Object: Observable & AnyObject & Sendable, Value: Sendable, Env: Sendable { let box = SendableKeyPath(keyPath: keyPath) let envKeyPathBox = SendableKeyPath(keyPath: envKeyPath) - return task(id: id, priority: priority) { input, env in + return .task(id: id, priority: priority) { input, env in do { let weakObject = WeakObject(object: env[keyPath: envKeyPathBox.keyPath]) await handler(input, try observedValue(weakObject, keyPath: box), env) @@ -96,12 +96,12 @@ extension Transducer where Effect == TransducerEffect { id: TaskIdentifier? = "observe", priority: TaskPriority? = nil, isolatedHandler: @escaping (any TransducerInput, Value, Env, isolated any Actor) async -> Void - ) -> Effect + ) -> Self where Object: Observable & AnyObject & Sendable, Value: Sendable { let box = SendableKeyPath(keyPath: keyPath) let envKeyPathBox = SendableKeyPath(keyPath: envKeyPath) - return task(id: id, priority: priority) { input, env, isolation in + return .task(id: id, priority: priority) { input, env, isolation in do { precondition( systemActor != nil && systemActor === isolation, @@ -165,12 +165,12 @@ extension Transducer where Effect == TransducerEffect { id: TaskIdentifier? = "observe", priority: TaskPriority? = nil, operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Value, Env) async -> Void - ) -> Effect + ) -> Self where Object: Observable & AnyObject & Sendable, Value: Sendable, Env: Sendable { let box = SendableKeyPath(keyPath: keyPath) let weakObject = WeakObject(object: object) - return task(id: id, priority: priority) { input, env in + return .task(id: id, priority: priority) { input, env in do { await operation(input, try observedValue(weakObject, keyPath: box), env) while true { @@ -208,12 +208,12 @@ extension Transducer where Effect == TransducerEffect { id: TaskIdentifier? = "observe", priority: TaskPriority? = nil, isolatedOperation: @escaping (any TransducerInput, Value, Env, isolated any Actor) async -> Void - ) -> Effect + ) -> Self where Object: Observable & AnyObject & Sendable, Value: Sendable { let box = SendableKeyPath(keyPath: keyPath) let weakObject = WeakObject(object: object) - return task(id: id, priority: priority) { input, env, isolation in + return .task(id: id, priority: priority) { input, env, isolation in do { precondition( systemActor != nil && systemActor === isolation, diff --git a/Sources/EffectComponents/Transducer/Transducer.Effects.swift b/Sources/EffectComponents/Transducer/TransducerEffect.factories.swift similarity index 76% rename from Sources/EffectComponents/Transducer/Transducer.Effects.swift rename to Sources/EffectComponents/Transducer/TransducerEffect.factories.swift index 95d9d79..e15191b 100644 --- a/Sources/EffectComponents/Transducer/Transducer.Effects.swift +++ b/Sources/EffectComponents/Transducer/TransducerEffect.factories.swift @@ -1,4 +1,4 @@ -extension Transducer where Effect == TransducerEffect { +extension TransducerEffect { /// Returns an effect which when invoked starts an async throwing operation isolated to a global actor /// tracked by the effect engine. @@ -46,7 +46,7 @@ extension Transducer where Effect == TransducerEffect { priority: TaskPriority? = nil, option: TaskExecutionOption = .switchToLatest, operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async throws -> Output? - ) -> Effect { + ) -> Self { .init(._task(id: id, priority: priority, option: option, operation: operation)) } @@ -96,7 +96,7 @@ extension Transducer where Effect == TransducerEffect { priority: TaskPriority? = nil, option: TaskExecutionOption = .switchToLatest, operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async throws -> Void - ) -> Effect where Env: Sendable { + ) -> Self where Env: Sendable { .init(._task(id: id, priority: priority, option: option, operation: { input, env in try await operation(input, env) return nil @@ -150,7 +150,7 @@ extension Transducer where Effect == TransducerEffect { priority: TaskPriority? = nil, option: TaskExecutionOption = .switchToLatest, isolatedOperation: @escaping (any TransducerInput, Env, isolated any Actor) async throws -> Output? - ) -> Effect { + ) -> Self { .init(._taskIsolated(id: id, priority: priority, option: option, isolatedOperation: isolatedOperation)) } @@ -200,7 +200,7 @@ extension Transducer where Effect == TransducerEffect { priority: TaskPriority? = nil, option: TaskExecutionOption = .switchToLatest, isolatedOperation: sending @escaping (any TransducerInput, Env, isolated any Actor) async throws -> Void - ) -> Effect { + ) -> Self { .init(._taskIsolated(id: id, priority: priority, option: option, isolatedOperation: { input, env, isolation in try await isolatedOperation(input, env, isolation) return nil @@ -209,7 +209,7 @@ extension Transducer where Effect == TransducerEffect { } -extension Transducer where Effect == TransducerEffect { +extension TransducerEffect { /// Return an effect which when invoked executes a synchronous step that may produce the next /// event to process immediately. @@ -234,7 +234,7 @@ extension Transducer where Effect == TransducerEffect { @inline(__always) public static func action( _ action: @escaping (Env) -> Event? - ) -> Effect { + ) -> Self { .init(._actionSync(action)) } @@ -253,7 +253,7 @@ extension Transducer where Effect == TransducerEffect { @inline(__always) public static func action( _ action: @escaping (Env) -> Void - ) -> Effect { + ) -> Self { .init(._actionSync({ env in action(env) return nil @@ -282,7 +282,7 @@ extension Transducer where Effect == TransducerEffect { @inline(__always) public static func action( _ action: sending @escaping @isolated(any) (Env) async -> sending Event? - ) -> Effect { + ) -> Self { .init(._actionAsync(action)) } @@ -300,7 +300,7 @@ extension Transducer where Effect == TransducerEffect { @inline(__always) public static func action( _ action: sending @escaping @isolated(any) (Env) async -> Void - ) -> Effect where Env: Sendable { + ) -> Self where Env: Sendable { .init(._actionAsync({ env in await action(env) return nil @@ -329,7 +329,7 @@ extension Transducer where Effect == TransducerEffect { @inline(__always) public static func action( _ action: @escaping (Env, isolated any Actor) async -> sending Event? - ) -> Effect { + ) -> Self { .init(._actionAsyncIsolated(action)) } @@ -347,7 +347,7 @@ extension Transducer where Effect == TransducerEffect { @inline(__always) public static func action( _ action: sending @escaping (Env, isolated any Actor) async -> Void - ) -> Effect { + ) -> Self { .init(._actionAsyncIsolated( { env, isolated in await action(env, isolated) return nil @@ -356,7 +356,7 @@ extension Transducer where Effect == TransducerEffect { } -extension Transducer where Effect == TransducerEffect { +extension TransducerEffect { /// Returns an effect which, when invoked, feeds an event back into `update` immediately /// in the current synchronous turn. @@ -367,7 +367,7 @@ extension Transducer where Effect == TransducerEffect { /// - Parameter event: The next event to feed directly back into `update`. /// - Returns: An effect. @inline(__always) - public static func send(_ event: Event) -> Effect { + public static func send(_ event: Event) -> Self { .init(._event(event)) } @@ -387,7 +387,7 @@ extension Transducer where Effect == TransducerEffect { /// - Returns: An effect. @available(*, deprecated, message: "Use send(_:) instead to avoid shadowing with the `update` parameter named `event`.") @inline(__always) - public static func event(_ event: Event) -> Effect { + public static func event(_ event: Event) -> Self { send(event) } @@ -396,7 +396,7 @@ extension Transducer where Effect == TransducerEffect { /// - Parameter id: The logical task identifier to cancel. /// - Returns: An effect. @inline(__always) - public static func cancel(_ id: TaskIdentifier) -> Effect { + public static func cancel(_ id: TaskIdentifier) -> Self { .init(._cancel(id)) } @@ -416,140 +416,7 @@ extension Transducer where Effect == TransducerEffect { /// - Parameter effects: The ordered effects to execute from left to right. /// - Returns: An effect. @inline(__always) - public static func sequence(_ effects: [Effect]) -> Effect { + public static func sequence(_ effects: [TransducerEffect]) -> Self { .init(._sequence(effects)) } } - -// deprecated -#if false -extension Transducer where Effect == TransducerEffect { - - /// Returns an effect which, when invoked, starts a fire-and-forget async task that communicates - /// back through events. - /// - /// Use for long-running background work — timers, observers, subscriptions — where - /// the caller does not need to await a result. The `operation` closure receives an - /// ``Input`` handle and the captured `Env`; any return value is discarded. - /// - /// Managed cancellation follows ``task(id:priority:option:operation:)`` semantics: - /// if the runtime cancels this task, that cancellation takes precedence over any - /// racing late failure from the operation. - /// - /// ```swift - /// return run(id: "ticker") { input, env in - /// do { - /// while true { - /// try await env.clock.sleep(for: .seconds(1)) - /// input(.tick) - /// } - /// } catch {} - /// } - /// ``` - /// - /// - Parameters: - /// - id: Optional logical identifier for tracking and overlap policy. - /// - priority: Optional `TaskPriority` for the launched task. - /// - option: The overlap policy to apply when another task with the same - /// identifier is started. - /// - operation: The fire-and-forget async work to perform. - /// - Returns: An effect. - @inline(__always) - public static func run( - id: TaskIdentifier? = nil, - priority: TaskPriority? = nil, - option: TaskExecutionOption = .switchToLatest, - operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async -> Void - ) -> Effect where Env: Sendable { - .init(._task(id: id, priority: priority, option: option) { input, env in - await operation(input, env) - return nil - }) - } - - /// Returns an effect which, when invoked, starts a fire-and-forget async task that communicates - /// back through events. - /// - /// Use for long-running background work — timers, observers, subscriptions — where - /// the caller does not need to await a result. The `operation` closure receives an - /// ``Input`` handle and the captured `Env`; any return value is discarded. - /// - /// Managed cancellation follows ``task(id:priority:option:isolatedOperation:)`` semantics: - /// if the runtime cancels this task, that cancellation takes precedence over any - /// racing late failure from the operation. - /// - /// ```swift - /// return run(id: "ticker") { input, env in - /// do { - /// while true { - /// try await env.clock.sleep(for: .seconds(1)) - /// input(.tick) - /// } - /// } catch {} - /// } - /// ``` - /// - /// - Parameters: - /// - systemActor: The host actor isolation forwarded into `isolatedOperation`. - /// - id: Optional logical identifier for tracking and overlap policy. - /// - priority: Optional `TaskPriority` for the launched task. - /// - option: The overlap policy to apply when another task with the same - /// identifier is started. - /// - isolatedOperation: The fire-and-forget async work to perform on the host actor. - /// - Returns: An effect. - @inline(__always) - public static func run( - systemActor: isolated (any Actor)? = #isolation, - id: TaskIdentifier? = nil, - priority: TaskPriority? = nil, - option: TaskExecutionOption = .switchToLatest, - isolatedOperation: @escaping (any TransducerInput, Env, isolated any Actor) async -> Void - ) -> Effect where Env: Sendable { - .init(._taskIsolated(id: id, priority: priority, option: option) { input, env, isolation in - precondition( - systemActor != nil && systemActor === isolation, - "taskIsolated requires a non-nil matching system actor. Actor hosts must provide isolation. Expected \(String(describing: systemActor)), got \(isolation)." - ) - await isolatedOperation(input, env, isolation) - return nil - }) - } - - /// Starts an async task whose result is returned to the caller of ``Input/request(_:)``. - /// - /// The `operation` closure performs its work, drives the FSM to a completion event - /// via `await input.request(...)`, and returns the resulting `Output?` to the - /// original waiter. Use this when the call site needs to `await` the outcome of an - /// async operation. - /// - /// Managed cancellation follows ``task(id:priority:option:operation:)`` semantics: - /// if the runtime cancels this task, that cancellation takes precedence over any - /// racing late failure from the operation. - /// - /// ```swift - /// return .request(id: "load") { input, env in - /// let user = await env.api.fetchUser() - /// return await input.request(.loaded(user)) - /// } - /// ``` - /// - /// - Parameters: - /// - id: Optional logical identifier for tracking and overlap policy. - /// - priority: Optional `TaskPriority` for the launched task. - /// - option: The overlap policy to apply when another task with the same - /// identifier is started. - /// - operation: The async work whose terminal result resumes the original waiter. - /// - Returns: An effect. - @inline(__always) - public static func request( - id: TaskIdentifier? = nil, - priority: TaskPriority? = nil, - option: TaskExecutionOption = .switchToLatest, - operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async -> Output? - ) -> Effect { - .init(._task(id: id, priority: priority, option: option, operation: operation)) - } - -} - -#endif diff --git a/Sources/EffectComponents/Transducer/TransducerEffect.swift b/Sources/EffectComponents/Transducer/TransducerEffect.swift index 25a3fe4..76b50e3 100644 --- a/Sources/EffectComponents/Transducer/TransducerEffect.swift +++ b/Sources/EffectComponents/Transducer/TransducerEffect.swift @@ -44,9 +44,10 @@ /// - `Output`: The value type returned to a caller suspended on ``Input/request(_:)``. /// Use `Void` when no return value is needed. public struct TransducerEffect { + typealias _Type = EffectType let type: EffectType - init(_ type: EffectType) { + init(_ type: _Type) { self.type = type } } diff --git a/Tests/EffectComponents/AsyncActionRuntimeTests.swift b/Tests/EffectComponents/AsyncActionRuntimeTests.swift index 7247972..23c7a1e 100644 --- a/Tests/EffectComponents/AsyncActionRuntimeTests.swift +++ b/Tests/EffectComponents/AsyncActionRuntimeTests.swift @@ -46,7 +46,7 @@ struct AsyncActionRuntimeTests { switch event { case .start: state.phases.append("start") - return action { env in + return .action { env in env.started.fulfill() await env.release.wait() return .finished @@ -126,7 +126,7 @@ struct AsyncActionRuntimeTests { switch event { case .first: state.events.append("first") - return action { env in + return .action { env in env.firstStartedExpectation.fulfill() try? await Task.sleep(nanoseconds: 10_000_000) return .firstFinished @@ -138,10 +138,10 @@ struct AsyncActionRuntimeTests { case .second: state.events.append("second") - return Self.event(.finished) + return .send(.finished) case .finished: - return action { env in + return .action { env in env.finishedExpectation.fulfill() return nil } diff --git a/Tests/EffectComponents/EffectActorTests.swift b/Tests/EffectComponents/EffectActorTests.swift index 6b25216..f4404e0 100644 --- a/Tests/EffectComponents/EffectActorTests.swift +++ b/Tests/EffectComponents/EffectActorTests.swift @@ -39,7 +39,7 @@ struct EffectActorTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .start: - return task(id: "work") { _, env in + return .task(id: "work") { _, env in env.started.fulfill() try await Task.sleep(nanoseconds: 10_000_000_000) return "done" @@ -75,12 +75,12 @@ struct EffectActorTests { switch (state, event) { case (.start, .start): state = .running - return Self.event(.tick) + return .send(.tick) case (.start, .tick): return nil case (.running, .tick): state = .finished - return action { env in + return .action { env in env.finishedExpectation.fulfill() } case (.running, .start): @@ -140,7 +140,7 @@ struct EffectActorTests { switch (state, event) { case (.start, .start): state = .initializing - return action { env in + return .action { env in await env.setUpCalled() return .didSetup } @@ -151,19 +151,19 @@ struct EffectActorTests { return nil case (.idle, .tick): - return action { env in + return .action { env in await env.tickCalled() } case (.idle, .teardown): state = .tearingDown - return action { env in + return .action { env in return .completed } case (.tearingDown, .completed): state = .terminal - return action { env in + return .action { env in env.finishedExpectation.fulfill() } diff --git a/Tests/EffectComponents/EffectObservableTests.swift b/Tests/EffectComponents/EffectObservableTests.swift index 8135be2..41f01f1 100644 --- a/Tests/EffectComponents/EffectObservableTests.swift +++ b/Tests/EffectComponents/EffectObservableTests.swift @@ -38,7 +38,7 @@ struct EffectObservableTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .start: - return task(id: "work") { _, env in + return .task(id: "work") { _, env in env.started.fulfill() do { while true { @@ -66,7 +66,7 @@ struct EffectObservableTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .start: - return task(id: "work") { _, _ in + return .task(id: "work") { _, _ in throw LatchedSystemError.boom } } @@ -272,4 +272,4 @@ struct EffectObservableTests { } } -#endif \ No newline at end of file +#endif diff --git a/Tests/EffectComponents/EffectViewInputTests.swift b/Tests/EffectComponents/EffectViewInputTests.swift index 945c5f0..0540fed 100644 --- a/Tests/EffectComponents/EffectViewInputTests.swift +++ b/Tests/EffectComponents/EffectViewInputTests.swift @@ -172,7 +172,7 @@ struct EffectViewInputTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: - return task(id: "load") { input, _ in + return .task(id: "load") { input, _ in do { try await Task.sleep(for: .milliseconds(1)) } catch { @@ -218,7 +218,7 @@ struct EffectViewInputTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: - return task(id: "load") { _, _ in + return .task(id: "load") { _, _ in throw TestError.boom } } diff --git a/Tests/EffectComponents/EffectViewTests.swift b/Tests/EffectComponents/EffectViewTests.swift index 26b87dd..3900758 100644 --- a/Tests/EffectComponents/EffectViewTests.swift +++ b/Tests/EffectComponents/EffectViewTests.swift @@ -182,7 +182,7 @@ struct EffectViewTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: - return task(id: "fetch") { input, _ in try input.post(Event.didLoad) } + return .task(id: "fetch") { input, _ in try input.post(Event.didLoad) } case .didLoad: state.loaded = true return nil @@ -217,7 +217,7 @@ struct EffectViewTests { switch event { case .start: state.running = true - return task(id: "ticker") { input, _ in + return .task(id: "ticker") { input, _ in do { // run indefinitely, or until the "ticker" task gets cancelled while true { @@ -234,7 +234,7 @@ struct EffectViewTests { return nil case .stop: state.running = false - return cancel("ticker") + return .cancel("ticker") } } } @@ -284,8 +284,8 @@ struct EffectViewTests { enum Event: Sendable { case begin, step, done } static func update(_ state: inout State, event: Event) -> Effect? { switch event { - case .begin: state.phase = 1; return action { _ in Event.step } - case .step: state.phase = 2; return action { _ in Event.done } + case .begin: state.phase = 1; return .action { _ in Event.step } + case .step: state.phase = 2; return .action { _ in Event.done } case .done: state.phase = 3; return nil } } @@ -325,7 +325,7 @@ struct EffectViewTests { switch event { case .startFirst: // Long-running task that never ticks on its own. - return task(id: "worker") { input, env in + return .task(id: "worker") { input, env in do { try await Task.sleep(nanoseconds: env.timeout) } catch { @@ -334,9 +334,9 @@ struct EffectViewTests { } case .refresh: // Cancel stale worker, immediately start a fresh one that ticks. - return sequence([ - cancel("worker"), - task(id: "worker") { input, _ in try input.post(Event.tick) }, + return .sequence([ + .cancel("worker"), + .task(id: "worker") { input, _ in try input.post(Event.tick) }, ]) case .tick: state.ticks += 1 @@ -388,7 +388,7 @@ struct EffectViewTests { let timeout: UInt64 = 5_000_000_000 - let (hostingController, window) = try await embedInWindowAndMakeKey( + let (_, window) = try await embedInWindowAndMakeKey( TestView(initialState: T.State()) { binding in EffectView(of: T.self, state: binding) { _, input in Color.clear.onAppear { @@ -433,7 +433,7 @@ struct EffectViewTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .fetch: - return task(id: "fetch") { input, env in + return .task(id: "fetch") { input, env in try input.post(Event.loaded(env.value)) } case .loaded(let value): @@ -492,7 +492,7 @@ struct EffectViewTests { static func update(_ state: inout State, event: Event) -> TransducerEffect? { switch event { case .watch(let observable): - return observe(observable, keyPath: \.value) { input, value, env in + return .observe(observable, keyPath: \.value) { input, value, env in // Regarding: using observe with isolated action // Note: request is *nonisolated* for this Input. Thus we // cannot use `isolatedOperation` - we need to have @@ -576,12 +576,12 @@ struct EffectViewTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .start: - return observe(\.counter, keyPath: \.value, id: "observe") { input, value, env in + return .observe(\.counter, keyPath: \.value, id: "observe") { input, value, env in try? await input.request(.tick(value)) print("request(.tick(\(value))) finished") } case .stop: - return cancel("observe") + return .cancel("observe") case .tick(let v): state.latest = v state.probe.recordTick() diff --git a/Tests/EffectComponents/TaskSubscriptionTests.swift b/Tests/EffectComponents/TaskSubscriptionTests.swift index a0bfbc1..d610c07 100644 --- a/Tests/EffectComponents/TaskSubscriptionTests.swift +++ b/Tests/EffectComponents/TaskSubscriptionTests.swift @@ -67,7 +67,7 @@ extension TaskSubscriptionTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: - return task(id: "shared-load", option: .subscribe) { _, env in + return .task(id: "shared-load", option: .subscribe) { _, env in await env.counter.increment() env.started.fulfill() do { @@ -150,7 +150,7 @@ extension TaskSubscriptionTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: - return task(id: "shared-load", option: .subscribe) { _, env in + return .task(id: "shared-load", option: .subscribe) { _, env in await env.counter.increment() env.started.fulfill() try? await env.release.await(nanoseconds: env.timeout) @@ -232,7 +232,7 @@ extension TaskSubscriptionTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: - return task(id: "shared-load", option: .subscribe) { _, env in + return .task(id: "shared-load", option: .subscribe) { _, env in let count = await env.counter.increment() return "output-\(count)" } @@ -283,7 +283,7 @@ extension TaskSubscriptionTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: - return task(id: "shared-load", option: .subscribe) { _, env in + return .task(id: "shared-load", option: .subscribe) { _, env in let invocation = await env.counter.increment() if invocation > 1 { return "fresh-output-\(invocation)" @@ -305,7 +305,7 @@ extension TaskSubscriptionTests { } } case .stop: - return cancel("shared-load") + return .cancel("shared-load") } } @@ -389,7 +389,7 @@ extension TaskSubscriptionTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: - return task(id: "shared-load", option: .subscribe) { _, env in + return .task(id: "shared-load", option: .subscribe) { _, env in let invocation = await env.counter.increment() if invocation > 1 { return "fresh-output-\(invocation)" @@ -405,7 +405,7 @@ extension TaskSubscriptionTests { } } case .stop: - return cancel("shared-load") + return .cancel("shared-load") } } @@ -483,7 +483,7 @@ extension TaskSubscriptionTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .first: - return task(id: "replaceable", option: .switchToLatest) { _, env in + return .task(id: "replaceable", option: .switchToLatest) { _, env in let invocation = await env.counter.increment() if invocation == 1 { env.firstStartedExpectation.fulfill() @@ -500,7 +500,7 @@ extension TaskSubscriptionTests { return "replacement-output" } case .second: - return task(id: "replaceable", option: .switchToLatest) { _, env in + return .task(id: "replaceable", option: .switchToLatest) { _, env in let invocation = await env.counter.increment() if invocation == 1 { env.firstStartedExpectation.fulfill() @@ -588,7 +588,7 @@ extension TaskSubscriptionTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .first: - return task(id: "replaceable", option: .switchToLatest) { _, env in + return .task(id: "replaceable", option: .switchToLatest) { _, env in let invocation = await env.counter.increment() if invocation == 1 { env.firstStartedExpectation.fulfill() @@ -605,7 +605,7 @@ extension TaskSubscriptionTests { throw ReplacementFailure.boom } case .second: - return task(id: "replaceable", option: .switchToLatest) { _, env in + return .task(id: "replaceable", option: .switchToLatest) { _, env in let invocation = await env.counter.increment() if invocation == 1 { env.firstStartedExpectation.fulfill() @@ -683,7 +683,7 @@ extension TaskSubscriptionTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: - return task(id: nil, option: .subscribe) { _, _ in + return .task(id: nil, option: .subscribe) { _, _ in "anonymous-output" } }