diff --git a/MobiusCore/Source/EffectHandlers/EffectRouter.swift b/MobiusCore/Source/EffectHandlers/EffectRouter.swift index c22aa960..581bdf6a 100644 --- a/MobiusCore/Source/EffectHandlers/EffectRouter.swift +++ b/MobiusCore/Source/EffectHandlers/EffectRouter.swift @@ -16,8 +16,11 @@ import Foundation /// a parameter extracting function: `(Effect) -> EffectParameters?`. If this function returns a non-`nil` value, /// that route is taken and the non-`nil` value is sent as the input to the route. /// -/// These two routing criteria can be matched with one of four types of targets: +/// These two routing criteria can be matched with one of five types of targets: /// - `.to { effect in ... }`: A fire-and-forget style function of type `(EffectParameters) -> Void`. +/// - `.onMainActor().to { effect in ... }`: A fire-and-forget style function of type +/// `@MainActor @Sendable (EffectParameters) -> Void`. This is equivalent to +/// `.on(queue: .main).to { ... }` but keeps the closure explicitly main-actor-isolated. /// - `.toEvent { effect in ... }`: A function which returns an optional event to send back into the loop: /// `(EffectParameters) -> Event?`. This makes it easy to send a single event caused by the effect. /// - `.to(EffectHandler)`: This should be used for effects which require asynchronous behavior or produce more than diff --git a/MobiusCore/Source/EffectHandlers/EffectRouterDSL.swift b/MobiusCore/Source/EffectHandlers/EffectRouterDSL.swift index d3afd56a..175c657e 100644 --- a/MobiusCore/Source/EffectHandlers/EffectRouterDSL.swift +++ b/MobiusCore/Source/EffectHandlers/EffectRouterDSL.swift @@ -1,6 +1,8 @@ // Copyright Spotify AB. // SPDX-License-Identifier: Apache-2.0 +import Dispatch + public extension EffectRouter where Effect: Equatable { /// Add a route for effects which are equal to `constant`. /// @@ -35,6 +37,16 @@ public extension _PartialEffectRouter { } } + /// Route main-isolated effects through the same queue path as `.on(queue: .main)`. + /// + /// This returns a dedicated builder exposing `to(...)` for `@MainActor` closures. + #if compiler(>=5.10) + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) + func onMainActor() -> _MainActorPartialEffectRouter { + return _MainActorPartialEffectRouter(partialRouter: on(queue: .main)) + } + #endif + /// Route to a closure which returns an optional event when given the parameters as input. /// /// - Parameter eventClosure: a function which returns an optional event given some input. No events will be @@ -51,3 +63,48 @@ public extension _PartialEffectRouter { } } } + +#if compiler(>=5.10) +/// A `_MainActorPartialEffectRouter` represents the state between an `onMainActor` call and a `to`. +/// +/// Client code should not refer to this type directly. +public struct _MainActorPartialEffectRouter { + fileprivate let partialRouter: _PartialEffectRouter +} + +public extension _MainActorPartialEffectRouter { + /// Route to a `@MainActor` side-effecting closure. + /// + /// Dispatches through the `.on(queue: .main)` path and assumes actor isolation once scheduled on the main queue. + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) + func to( + _ fireAndForget: @MainActor @escaping (EffectParameters) -> Void + ) -> EffectRouter { + return partialRouter.to { parameters, callback in + MainActor.assumeIsolated { + fireAndForget(parameters) + } + callback.end() + return AnonymousDisposable {} + } + } +} + +public extension _MainActorPartialEffectRouter where EffectParameters == Void { + /// Route to a `@MainActor` side-effecting closure with no input parameters. + /// + /// Dispatches through the `.on(queue: .main)` path and assumes actor isolation once scheduled on the main queue. + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) + func to( + _ fireAndForget: @MainActor @escaping () -> Void + ) -> EffectRouter { + return partialRouter.to { _, callback in + MainActor.assumeIsolated { + fireAndForget() + } + callback.end() + return AnonymousDisposable {} + } + } +} +#endif diff --git a/MobiusCore/Test/EffectHandlers/EffectRouterDSLTests.swift b/MobiusCore/Test/EffectHandlers/EffectRouterDSLTests.swift index 6f0ecbfd..7e6860df 100644 --- a/MobiusCore/Test/EffectHandlers/EffectRouterDSLTests.swift +++ b/MobiusCore/Test/EffectHandlers/EffectRouterDSLTests.swift @@ -202,6 +202,52 @@ class EffectRouterDSLTests: QuickSpec { expect(didDispatchEvents).to(beFalse()) } + #if compiler(>=5.10) + it("Supports routing to an onMainActor side-effecting function") { + guard #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) else { + return + } + + let performedEffects = Recorder() + var didDispatchEvents = false + let parameterExtractor: (Effect) -> Effect? = { $0 == .effect1 ? .effect1 : nil } + let dslHandler = EffectRouter() + .routeEffects(withParameters: parameterExtractor).onMainActor().to { effect in + performedEffects.append(effect) + } + .asConnectable + .connect { _ in + didDispatchEvents = true + } + + dslHandler.accept(.effect1) + expect(performedEffects.items).toEventually(equal([.effect1])) + expect(didDispatchEvents).to(beFalse()) + } + + it("Supports routing to an onMainActor side-effecting function with no input parameters") { + guard #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) else { + return + } + + let effectPerformedCount = Recorder() + var didDispatchEvents = false + let parameterExtractor: (Effect) -> Void? = { $0 == .effect1 ? () : nil } + let dslHandler = EffectRouter() + .routeEffects(withParameters: parameterExtractor).onMainActor().to { + effectPerformedCount.append(1) + } + .asConnectable + .connect { _ in + didDispatchEvents = true + } + + dslHandler.accept(.effect1) + expect(effectPerformedCount.items).toEventually(equal([1])) + expect(didDispatchEvents).to(beFalse()) + } + #endif + it("Supports routing to an event-returning function") { var events: [Event] = [] let extractEffect1: (Effect) -> Effect? = { $0 == .effect1 ? .effect1 : nil }