Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion MobiusCore/Source/EffectHandlers/EffectRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions MobiusCore/Source/EffectHandlers/EffectRouterDSL.swift
Original file line number Diff line number Diff line change
@@ -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`.
///
Expand Down Expand Up @@ -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<Effect, EffectParameters, Event> {
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
Expand All @@ -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<Effect, EffectParameters, Event> {
fileprivate let partialRouter: _PartialEffectRouter<Effect, EffectParameters, Event>
}

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<Effect, Event> {
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<Effect, Event> {
return partialRouter.to { _, callback in
MainActor.assumeIsolated {
fireAndForget()
}
callback.end()
return AnonymousDisposable {}
}
}
}
#endif
46 changes: 46 additions & 0 deletions MobiusCore/Test/EffectHandlers/EffectRouterDSLTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Effect>()
var didDispatchEvents = false
let parameterExtractor: (Effect) -> Effect? = { $0 == .effect1 ? .effect1 : nil }
let dslHandler = EffectRouter<Effect, Event>()
.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<Int>()
var didDispatchEvents = false
let parameterExtractor: (Effect) -> Void? = { $0 == .effect1 ? () : nil }
let dslHandler = EffectRouter<Effect, Event>()
.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 }
Expand Down