Skip to content

Commit

Permalink
Deprecate the EffectHandler protocol
Browse files Browse the repository at this point in the history
  • Loading branch information
kmcbride committed Feb 18, 2025
1 parent f1d5683 commit 1892541
Show file tree
Hide file tree
Showing 11 changed files with 77 additions and 63 deletions.
72 changes: 43 additions & 29 deletions MobiusCore/Source/EffectHandlers/EffectExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,29 @@
import Foundation

final class EffectExecutor<Effect, Event>: Connectable {
private let handleEffect: (Effect, EffectCallback<Event>) -> Disposable
enum Operation {
case eventEmitting((Effect, EffectCallback<Event>) -> Disposable)
case eventReturning((Effect) -> Event?)
case sideEffecting((Effect) -> Void)
}

private let operation: Operation
private var output: Consumer<Event>?

private let lock = Lock()

// Keep track of each received effect's state.
// When an effect has completed, it should be removed from this dictionary.
// When disposing this effect handler, all entries must be removed.
private var handlingEffects: [Int64: EffectHandlingState<Event>] = [:]
private var ongoingEffects: [Int64: EffectHandlingState<Event>] = [:]
private var nextID = Int64(0)

init(handleInput: @escaping (Effect, EffectCallback<Event>) -> Disposable) {
self.handleEffect = handleInput
init(operation: Operation) {
self.operation = operation
}

deinit {
dispose()
}

func connect(_ consumer: @escaping Consumer<Event>) -> Connection<Effect> {
Expand All @@ -38,7 +48,31 @@ final class EffectExecutor<Effect, Event>: Connectable {
}
}

func handle(_ effect: Effect) {
private func handle(_ effect: Effect) {
switch operation {
case .eventEmitting(let handler): handleOngoing(effect, handler: handler)
case .eventReturning(let handler): handler(effect).map { event in output?(event) }
case .sideEffecting(let handler): handler(effect)
}
}

private func dispose() {
lock.synchronized {
// Dispose any effects currently being handled. We also need to `end` their callbacks to remove the
// references we are keeping to them.
ongoingEffects.values
.forEach {
$0.disposable.dispose()
$0.callback.end()
}

// Restore the state of this `Connectable` to its pre-connected state.
ongoingEffects = [:]
output = nil
}
}

private func handleOngoing(_ effect: Effect, handler: @escaping (Effect, EffectCallback<Event>) -> Disposable) {
let id: Int64 = lock.synchronized {
nextID += 1
return nextID
Expand All @@ -52,47 +86,27 @@ final class EffectExecutor<Effect, Event>: Connectable {
onEnd: { [weak self] in self?.delete(id: id) }
)

let disposable = handleEffect(effect, callback)

let disposable = handler(effect, callback)
store(id: id, callback: callback, disposable: disposable)

// We cannot know if `callback.end()` was called before `self.store(..)`. This check ensures that if
// the callback was ended early, the reference to it will be deleted.
if callback.ended {
delete(id: id)
}
}

func dispose() {
lock.synchronized {
// Dispose any effects currently being handled. We also need to `end` their callbacks to remove the
// references we are keeping to them.
handlingEffects.values
.forEach {
$0.disposable.dispose()
$0.callback.end()
}

// Restore the state of this `Connectable` to its pre-connected state.
handlingEffects = [:]
output = nil
}
}

private func store(id: Int64, callback: EffectCallback<Event>, disposable: Disposable) {
lock.synchronized {
handlingEffects[id] = EffectHandlingState(callback: callback, disposable: disposable)
ongoingEffects[id] = EffectHandlingState(callback: callback, disposable: disposable)
}
}

private func delete(id: Int64) {
lock.synchronized {
handlingEffects[id] = nil
ongoingEffects[id] = nil
}
}

deinit {
dispose()
}
}

private struct EffectHandlingState<Event> {
Expand Down
2 changes: 2 additions & 0 deletions MobiusCore/Source/EffectHandlers/EffectHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
/// call `callback.end()`.
///
/// Note: `EffectHandler` should be used in conjunction with an `EffectRouter`.
@available(*, deprecated)
public protocol EffectHandler {
associatedtype EffectParameters
associatedtype Event
Expand All @@ -33,6 +34,7 @@ public protocol EffectHandler {
}

/// A type-erased wrapper of the `EffectHandler` protocol.
@available(*, deprecated)
public struct AnyEffectHandler<EffectParameters, Event>: EffectHandler {
private let handleClosure: (EffectParameters, EffectCallback<Event>) -> Disposable

Expand Down
16 changes: 10 additions & 6 deletions MobiusCore/Source/EffectHandlers/EffectRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,21 @@ public struct _PartialEffectRouter<Effect, EffectParameters, Event> {
fileprivate let path: (Effect) -> EffectParameters?
fileprivate let queue: DispatchQueue?

func routed<C: Connectable>(
_ connectable: C
) -> EffectRouter<Effect, Event> where C.Input == EffectParameters, C.Output == Event {
let route = Route(extractParameters: path, connectable: connectable, queue: queue)
return EffectRouter(routes: routes + [route])
}

/// Route to an `EffectHandler`.
///
/// - Parameter effectHandler: the `EffectHandler` for the route in question.
@available(*, deprecated, message: "prefer routing directly to the handling closure, eg: .to(myEffectHandler.handle)")
public func to<Handler: EffectHandler>(
_ effectHandler: Handler
) -> EffectRouter<Effect, Event> where Handler.EffectParameters == EffectParameters, Handler.Event == Event {
let connectable = EffectExecutor(handleInput: effectHandler.handle)
let route = Route<Effect, Event>(extractParameters: path, connectable: connectable, queue: queue)
return EffectRouter(routes: routes + [route])
return routed(EffectExecutor(operation: .eventEmitting(effectHandler.handle)))
}

/// Route to a Connectable.
Expand All @@ -84,9 +90,7 @@ public struct _PartialEffectRouter<Effect, EffectParameters, Event> {
public func to<C: Connectable>(
_ connectable: C
) -> EffectRouter<Effect, Event> where C.Input == EffectParameters, C.Output == Event {
let connectable = ThreadSafeConnectable(connectable: connectable)
let route = Route(extractParameters: path, connectable: connectable, queue: queue)
return EffectRouter(routes: routes + [route])
return routed(ThreadSafeConnectable(connectable: connectable))
}

/// Handle an the current `Effect` asynchronously on the provided `DispatchQueue`
Expand Down
16 changes: 3 additions & 13 deletions MobiusCore/Source/EffectHandlers/EffectRouterDSL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public extension _PartialEffectRouter {
func to(
_ handle: @escaping (EffectParameters, EffectCallback<Event>) -> Disposable
) -> EffectRouter<Effect, Event> {
return to(AnyEffectHandler(handle: handle))
return routed(EffectExecutor(operation: .eventEmitting(handle)))
}

/// Route to a side-effecting closure.
Expand All @@ -28,11 +28,7 @@ public extension _PartialEffectRouter {
func to(
_ fireAndForget: @escaping (EffectParameters) -> Void
) -> EffectRouter<Effect, Event> {
return to { parameters, callback in
fireAndForget(parameters)
callback.end()
return AnonymousDisposable {}
}
return routed(EffectExecutor(operation: .sideEffecting(fireAndForget)))
}

/// Route to a closure which returns an optional event when given the parameters as input.
Expand All @@ -42,12 +38,6 @@ public extension _PartialEffectRouter {
func toEvent(
_ eventClosure: @escaping (EffectParameters) -> Event?
) -> EffectRouter<Effect, Event> {
return to { parameters, callback in
if let event = eventClosure(parameters) {
callback.send(event)
}
callback.end()
return AnonymousDisposable {}
}
return routed(EffectExecutor(operation: .eventReturning(eventClosure)))
}
}
8 changes: 5 additions & 3 deletions MobiusCore/Test/EffectHandlers/AnyEffectHandlerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Quick
private typealias Effect = String
private typealias Event = String

@available(*, deprecated)
class AnyEffectHandlerTests: QuickSpec {
// swiftlint:disable:next function_body_length
override func spec() {
Expand Down Expand Up @@ -48,7 +49,7 @@ class AnyEffectHandlerTests: QuickSpec {

context("when initialized with wrapped effect handler") {
beforeEach {
let wrapped = TestEffectHandler()
let wrapped = WrappedEffectHandler()
effectHandler = AnyEffectHandler(handler: wrapped)
}

Expand All @@ -57,7 +58,7 @@ class AnyEffectHandlerTests: QuickSpec {

context("when initialized with doubly wrapped effect handler") {
beforeEach {
let wrapped = TestEffectHandler()
let wrapped = WrappedEffectHandler()
let inner = AnyEffectHandler(handler: wrapped)
effectHandler = AnyEffectHandler(handler: inner)
}
Expand All @@ -68,7 +69,8 @@ class AnyEffectHandlerTests: QuickSpec {
}
}

private struct TestEffectHandler: EffectHandler {
@available(*, deprecated)
private struct WrappedEffectHandler: EffectHandler {
func handle(_ effect: Effect, _ callback: EffectCallback<Event>) -> Disposable {
callback.send(effect)
return AnonymousDisposable {
Expand Down
10 changes: 5 additions & 5 deletions MobiusCore/Test/EffectHandlers/EffectHandlerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ private enum Event {
class EffectHandlerTests: QuickSpec {
override func spec() {
describe("Handling effects with EffectHandler") {
var effectHandler: AnyEffectHandler<Effect, Event>!
var effectHandler: TestEffectHandler<Effect, Event>!
var executeEffect: ((Effect) -> Void)!
var receivedEvents: [Event]!

beforeEach {
effectHandler = AnyEffectHandler(handle: handleEffect)
effectHandler = handleEffect
receivedEvents = []
let callback = EffectCallback(
onSend: { event in
Expand All @@ -33,7 +33,7 @@ class EffectHandlerTests: QuickSpec {
onEnd: {}
)
executeEffect = { effect in
_ = effectHandler.handle(effect, callback)
_ = effectHandler(effect, callback)
}
}

Expand All @@ -53,13 +53,13 @@ class EffectHandlerTests: QuickSpec {
describe("Disposing EffectHandler") {
it("calls the returned disposable when disposing") {
var disposed = false
let effectHandler = AnyEffectHandler<Effect, Event> { _, _ in
let effectHandler: TestEffectHandler<Effect, Event> = { _, _ in
AnonymousDisposable {
disposed = true
}
}
let callback = EffectCallback<Event>(onSend: { _ in }, onEnd: {})
effectHandler.handle(.effect1, callback).dispose()
effectHandler(.effect1, callback).dispose()

expect(disposed).to(beTrue())
}
Expand Down
6 changes: 3 additions & 3 deletions MobiusCore/Test/EffectHandlers/EffectRouterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ class EffectRouterTests: QuickSpec {
receivedEvents = []
disposed1 = false
disposed2 = false
let effectHandler1 = AnyEffectHandler<Effect, Event> { _, callback in
let effectHandler1: TestEffectHandler<Effect, Event> = { _, callback in
callback.send(.eventForEffect1)
return AnonymousDisposable {
disposed1 = true
}
}
let effectHandler2 = AnyEffectHandler<Effect, Event> { _, callback in
let effectHandler2: TestEffectHandler<Effect, Event> = { _, callback in
callback.send(.eventForEffect2)
return AnonymousDisposable {
disposed2 = true
Expand Down Expand Up @@ -107,7 +107,7 @@ class EffectRouterTests: QuickSpec {
var dispose: (() -> Void)!

beforeEach {
let handler = AnyEffectHandler<Effect, Event> { _, _ in
let handler: TestEffectHandler<Effect, Event> = { _, _ in
AnonymousDisposable {}
}
let invalidRouter = EffectRouter<Effect, Event>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ private class EffectCollaborator {
}

extension EffectCollaborator {
func makeEffectHandler<Effect, Event>(replyEvent: Event) -> AnyEffectHandler<Effect, Event> {
return AnyEffectHandler<Effect, Event> { _, callback in
func makeEffectHandler<Effect, Event>(replyEvent: Event) -> TestEffectHandler<Effect, Event> {
return { _, callback in
let cancellationToken = self.asyncDoStuff {
callback.send(replyEvent)
callback.end()
Expand Down
2 changes: 1 addition & 1 deletion MobiusCore/Test/MobiusLoopTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ class MobiusLoopTests: QuickSpec {
beforeEach {
disposed.value = false
didReceiveEffect.value = false
let effectHandler = AnyEffectHandler<Int, Int> { _, _ in
let effectHandler: TestEffectHandler<Int, Int> = { _, _ in
didReceiveEffect.value = true
return AnonymousDisposable {
disposed.value = true
Expand Down
2 changes: 1 addition & 1 deletion MobiusCore/Test/NonReentrancyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class NonReentrancyTests: QuickSpec {
}
}

let testEffectHandler = AnyEffectHandler<Effect, Event> {
let testEffectHandler: TestEffectHandler<Effect, Event> = {
handleEffect($0, $1)
return AnonymousDisposable {}
}
Expand Down
2 changes: 2 additions & 0 deletions MobiusCore/Test/TestingUtil.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Foundation
@testable import MobiusCore
import Nimble

typealias TestEffectHandler<EffectParameters, Event> = (EffectParameters, EffectCallback<Event>) -> Disposable

class SimpleTestConnectable: Connectable {
var disposed = false

Expand Down

0 comments on commit 1892541

Please sign in to comment.