diff --git a/MobiusCore/Source/Mobius.swift b/MobiusCore/Source/Mobius.swift index 609e5768..d43ac4f0 100644 --- a/MobiusCore/Source/Mobius.swift +++ b/MobiusCore/Source/Mobius.swift @@ -74,7 +74,7 @@ public enum Mobius { return Builder( update: update, effectHandler: effectHandler, - eventSource: AnyEventSource({ _ in AnonymousDisposable(disposer: {}) }), + eventSource: AnyConnectable { _ in .init(acceptClosure: { _ in }, disposeClosure: {}) }, eventConsumerTransformer: { $0 }, logger: AnyMobiusLogger(NoopLogger()) ) @@ -104,14 +104,14 @@ public enum Mobius { public struct Builder { private let update: Update private let effectHandler: AnyConnectable - private let eventSource: AnyEventSource + private let eventSource: AnyConnectable private let logger: AnyMobiusLogger private let eventConsumerTransformer: ConsumerTransformer fileprivate init( update: Update, effectHandler: EffectHandler, - eventSource: AnyEventSource, + eventSource: AnyConnectable, eventConsumerTransformer: @escaping ConsumerTransformer, logger: AnyMobiusLogger ) where EffectHandler.Input == Effect, EffectHandler.Output == Event { @@ -140,7 +140,43 @@ public enum Mobius { return Builder( update: update, effectHandler: effectHandler, - eventSource: AnyEventSource(eventSource), + eventSource: AnyConnectable { consumer in + var disposable: Disposable? = eventSource.subscribe(consumer: consumer) + return .init( + acceptClosure: { _ in }, + disposeClosure: { + disposable?.dispose() + disposable = nil + } + ) + }, + eventConsumerTransformer: eventConsumerTransformer, + logger: logger + ) + } + + /// Return a copy of this builder with a new [event source] using a `Connectable`. + /// + /// If a `MobiusLoop` is created from the builder by calling `start`, the event source will be subscribed to + /// immediately, and the subscription will be disposed when the loop is disposed. + /// + /// If a `MobiusController` is created by calling `makeController`, the controller will subscribe to the event + /// source each time `start` is called on the controller, and dispose the subscription when `stop` is called. + /// + /// The loop will use the `Connectable` event source, to invoke the `Connection` + /// accept method every time the model changes. This allows to conditionally subscribe to different sources based + /// on the current state + /// + /// - Note: The event source will replace any existing event source. + /// + /// - Parameter eventSource: The event source to set on the new builder. + /// - Returns: An updated Builder. + /// + public func withEventSource(_ eventSource: Source) -> Builder where Source.Input == Model, Source.Output == Event { + return Builder( + update: update, + effectHandler: effectHandler, + eventSource: AnyConnectable(eventSource), eventConsumerTransformer: eventConsumerTransformer, logger: logger ) diff --git a/MobiusCore/Source/MobiusLoop.swift b/MobiusCore/Source/MobiusLoop.swift index 0e7dde17..59a11c54 100644 --- a/MobiusCore/Source/MobiusLoop.swift +++ b/MobiusCore/Source/MobiusLoop.swift @@ -25,6 +25,7 @@ public final class MobiusLoop: Disposable { private var workBag: WorkBag private var effectConnection: Connection! = nil + private var eventSourceConnection: Connection! = nil private var consumeEvent: Consumer! = nil private let modelPublisher: ConnectablePublisher @@ -35,7 +36,7 @@ public final class MobiusLoop: Disposable { init( model: Model, update: Update, - eventSource: AnyEventSource, + eventSource: AnyConnectable, eventConsumerTransformer: ConsumerTransformer, effectHandler: AnyConnectable, effects: [Effect], @@ -72,12 +73,14 @@ public final class MobiusLoop: Disposable { // These must be set up after consumeEvent, which refers to self; that’s why they need to be IUOs. self.effectConnection = effectHandler.connect(consumeEvent) - let eventSourceDisposable = eventSource.subscribe(consumer: consumeEvent) + self.eventSourceConnection = eventSource.connect { event in + consumeEvent(event) + } self.disposable = CompositeDisposable(disposables: [ effectConnection, modelPublisher, - eventSourceDisposable, + eventSourceConnection, ]) // Prime the modelPublisher, and queue up any initial effects. @@ -154,6 +157,7 @@ public final class MobiusLoop: Disposable { private func processNext(_ next: Next) { if let newModel = next.model { model = newModel + eventSourceConnection.accept(model) modelPublisher.post(model) } diff --git a/MobiusCore/Test/InitializationTests.swift b/MobiusCore/Test/InitializationTests.swift new file mode 100644 index 00000000..3d4734ae --- /dev/null +++ b/MobiusCore/Test/InitializationTests.swift @@ -0,0 +1,134 @@ +// Copyright 2019-2024 Spotify AB. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +@testable import MobiusCore +import Nimble +import Quick + +class InitializationTests: QuickSpec { + // swiftlint:disable:next function_body_length + override func spec() { + describe("Initialization") { + var builder: Mobius.Builder! + var updateFunction: Update! + var loop: MobiusLoop! + var receivedModels: [String]! + var modelObserver: Consumer! + var effectHandler: RecordingTestConnectable! + var eventSource: TestEventSource! + var connectableEventSource: TestConnectableEventSource! + + beforeEach { + receivedModels = [] + + modelObserver = { receivedModels.append($0) } + + updateFunction = Update { _, event in + if event == "event that triggers effect" { + return Next.next(event, effects: [event]) + } else { + return Next.next(event) + } + } + + effectHandler = RecordingTestConnectable() + eventSource = TestEventSource() + connectableEventSource = .init() + + } + + it("should process init") { + builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler) + + loop = builder.start(from: "the first model") + + loop.addObserver(modelObserver) + + expect(receivedModels).to(equal(["the first model"])) + } + + it("should process init and then events") { + builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler) + + loop = builder.start(from: "the first model") + + loop.addObserver(modelObserver) + loop.dispatchEvent("event that triggers effect") + + expect(receivedModels).to(equal(["the first model", "event that triggers effect"])) + } + + it("should process init before events from connectable event source") { + builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler) + .withEventSource(connectableEventSource) + + connectableEventSource.dispatch("ignored event from connectable event source") + loop = builder.start(from: "the first model") + loop.addObserver(modelObserver) + + connectableEventSource.dispatch("second event from connectable event source") + + // The first event was sent before the loop started so it should be ignored. The second should go through + expect(receivedModels).to(equal(["the first model", "second event from connectable event source"])) + } + + it("should process init before events from event source") { + builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler) + .withEventSource(eventSource) + + eventSource.dispatch("ignored event from event source") + loop = builder.start(from: "the first model") + loop.addObserver(modelObserver) + + eventSource.dispatch("second event from event source") + + // The first event was sent before the loop started so it should be ignored. The second should go through + expect(receivedModels).to(equal(["the first model", "second event from event source"])) + } + } + } +} + +// Emits values before returning the connection +class EagerTestConnectable: Connectable { + private(set) var consumer: Consumer? + private(set) var recorder: Recorder + private(set) var eagerValue: String + + private(set) var connection: Connection! + + init(eagerValue: String) { + self.recorder = Recorder() + self.eagerValue = eagerValue + } + + func connect(_ consumer: @escaping (String) -> Void) -> Connection { + self.consumer = consumer + connection = Connection(acceptClosure: accept, disposeClosure: dispose) // Will retain self + connection.accept(eagerValue) // emit before returning + return connection + } + + func dispatch(_ string: String) { + consumer?(string) + } + + func accept(_ value: String) { + recorder.append(value) + } + + func dispose() { + } +} diff --git a/MobiusCore/Test/MobiusControllerTests.swift b/MobiusCore/Test/MobiusControllerTests.swift index 6b4bdd27..7597af09 100644 --- a/MobiusCore/Test/MobiusControllerTests.swift +++ b/MobiusCore/Test/MobiusControllerTests.swift @@ -28,8 +28,11 @@ class MobiusControllerTests: QuickSpec { override func spec() { describe("MobiusController") { var controller: MobiusController! + var updateFunction: Update! + var initiate: Initiate! var view: RecordingTestConnectable! var eventSource: TestEventSource! + var connectableEventSource: TestConnectableEventSource! var effectHandler: RecordingTestConnectable! var activateInitiator: Bool! @@ -42,13 +45,13 @@ class MobiusControllerTests: QuickSpec { view = RecordingTestConnectable(expectedQueue: self.viewQueue) let loopQueue = self.loopQueue - let updateFunction = Update { model, event in + updateFunction = .init { model, event in dispatchPrecondition(condition: .onQueue(loopQueue)) return .next("\(model)-\(event)") } activateInitiator = false - let initiate: Initiate = { model in + initiate = .init { model in if activateInitiator { return First(model: "\(model)-init", effects: ["initEffect"]) } else { @@ -57,6 +60,7 @@ class MobiusControllerTests: QuickSpec { } eventSource = TestEventSource() + effectHandler = RecordingTestConnectable() controller = Mobius.loop(update: updateFunction, effectHandler: effectHandler) @@ -356,6 +360,67 @@ class MobiusControllerTests: QuickSpec { } } + describe("dispatching events using a connectable") { + beforeEach { + // Rebuild the controller but use the Connectable instead of plain EventSource + connectableEventSource = .init() + + controller = Mobius.loop(update: updateFunction, effectHandler: effectHandler) + .withEventSource(connectableEventSource) + .makeController( + from: "S", + initiate: initiate, + loopQueue: self.loopQueue, + viewQueue: self.viewQueue + ) + controller.connectView(view) + controller.start() + } + + it("should dispatch events from the event source") { + connectableEventSource.dispatch("event source event") + + expect(view.recorder.items).toEventually(equal(["S", "S-event source event"])) + } + + it("should receive models from the event source") { + view.dispatch("new model") + expect(connectableEventSource.models).toEventually(equal(["S", "S-new model"])) + } + + it("should allow the event source to change with model updates") { + connectableEventSource.shouldProcessModel = { model in + model != "S-ignore" + } + + view.dispatch("ignore") + view.dispatch("new model 2") + expect(connectableEventSource.models).toEventually(equal(["S", "S-ignore-new model 2"])) + } + + it("should replace the event source") { + connectableEventSource = .init() + + controller = Mobius.loop(update: updateFunction, effectHandler: effectHandler) + .withEventSource(eventSource) + .withEventSource(connectableEventSource) + .makeController( + from: "S", + initiate: initiate, + loopQueue: self.loopQueue, + viewQueue: self.viewQueue + ) + controller.connectView(view) + controller.start() + + eventSource.dispatch("event source event") + connectableEventSource.dispatch("connectable event source event") + + // The connectable event source should have replaced the original normal event source + expect(connectableEventSource.models).toEventually(equal(["S", "S-connectable event source event"])) + } + } + describe("deallocating") { var modelObserver: MockConsumerConnectable! var effectObserver: MockConnectable! diff --git a/MobiusCore/Test/TestingUtil.swift b/MobiusCore/Test/TestingUtil.swift index 54842362..9e5fe406 100644 --- a/MobiusCore/Test/TestingUtil.swift +++ b/MobiusCore/Test/TestingUtil.swift @@ -193,3 +193,64 @@ class TestEventSource: EventSource { } } } + +class TestConnectableEventSource: Connectable { + typealias Input = Model + typealias Output = Event + + enum Connection { + case disposed + case active(Consumer) + } + private(set) var connections: [Connection] = [] + private(set) var models: [Model] = [] + private var pendingEvent: Event? + var shouldProcessModel: ((Model) -> Bool) = { _ in true } + + var activeConnections: [Consumer] { + return connections.compactMap { + switch $0 { + case .disposed: + return nil + case .active(let consumer): + return consumer + } + } + } + + var allDisposed: Bool { + return activeConnections.isEmpty + } + + func connect(_ consumer: @escaping MobiusCore.Consumer) -> MobiusCore.Connection { + let index = connections.count + connections.append(.active(consumer)) + + if let event = pendingEvent { + consumer(event) + pendingEvent = nil + } + + return .init( + acceptClosure: { [weak self] model in + guard let self else { return } + if shouldProcessModel(model) { + models.append(model) + } + }, disposeClosure: { [weak self] in + self?.connections[index] = .disposed + } + ) + } + + // Set an event to dispatch immediately when subscribed + func dispatchOnSubscribe(_ event: Event) { + pendingEvent = event + } + + func dispatch(_ event: Event) { + activeConnections.forEach { + $0(event) + } + } +}