Skip to content
Merged
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
84 changes: 54 additions & 30 deletions MobiusCore/Source/MobiusController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,15 @@ import Foundation
public final class MobiusController<Model, Event, Effect> {
typealias Loop = MobiusLoop<Model, Event, Effect>
typealias ViewConnectable = AsyncDispatchQueueConnectable<Model, Event>
typealias ViewConnection = Connection<Model>

private struct StoppedState {
var modelToStartFrom: Model
var viewConnectable: ViewConnectable?

var viewConnectables: [UUID: ViewConnectable]
}

private struct RunningState {
var loop: Loop
var viewConnectable: ViewConnectable?
var viewConnectables: [UUID: ViewConnectable]
var disposables: CompositeDisposable
}

Expand Down Expand Up @@ -78,7 +76,7 @@ public final class MobiusController<Model, Event, Effect> {
self.viewQueue = viewQueue

let state = State(
state: StoppedState(modelToStartFrom: initialModel, viewConnectable: nil),
state: StoppedState(modelToStartFrom: initialModel, viewConnectables: [:]),
queue: loopQueue
)
self.state = state
Expand Down Expand Up @@ -130,25 +128,24 @@ public final class MobiusController<Model, Event, Effect> {

/// Connect a view to this controller.
///
/// May not be called while the loop is running.
///
Comment on lines -133 to -134
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this still true even after the change?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is still true but I removed it because it duplicates information that already exists in the attention message

/// The `Connectable` will be given an event consumer, which the view should use to send events to the `MobiusLoop`.
/// The view should also return a `Connection` that accepts models and renders them. Disposing the connection should
/// make the view stop emitting events.
///
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running or if the controller already is
/// connected
/// - Parameter connectable: the view to connect
/// - Returns: an identifier that can be used to disconnect the view
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running
@discardableResult
public func connectView<ViewConnectable: Connectable>(
_ connectable: ViewConnectable
) where ViewConnectable.Input == Model, ViewConnectable.Output == Event {
) -> UUID where ViewConnectable.Input == Model, ViewConnectable.Output == Event {
do {
let id = UUID()
try state.mutate { stoppedState in
guard stoppedState.viewConnectable == nil else {
throw ErrorMessage(message: "\(Self.debugTag): only one view may be connected at a time")
}

stoppedState.viewConnectable = AsyncDispatchQueueConnectable(connectable, acceptQueue: viewQueue)
stoppedState.viewConnectables[id] = AsyncDispatchQueueConnectable(connectable, acceptQueue: viewQueue)
}

return id
} catch {
MobiusHooks.errorHandler(
errorMessage(error, default: "\(Self.debugTag): cannot connect a view while running"),
Expand All @@ -162,16 +159,44 @@ public final class MobiusController<Model, Event, Effect> {
///
/// May not be called directly from an effect handler running on the controller’s loop queue.
///
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running or if there isn't anything to
/// disconnect
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running, if there is more than 1 connection,
/// or if there isn't anything to disconnect
public func disconnectView() {
do {
try state.mutate { stoppedState in
guard stoppedState.viewConnectable != nil else {
guard stoppedState.viewConnectables.count <= 1 else {
throw ErrorMessage(message: "\(Self.debugTag): missing view connection id, cannot disconnect")
}

guard let id = stoppedState.viewConnectables.keys.first else {
throw ErrorMessage(message: "\(Self.debugTag): no view connected, cannot disconnect")
}

stoppedState.viewConnectable = nil
stoppedState.viewConnectables[id] = nil
}
} catch {
MobiusHooks.errorHandler(
errorMessage(error, default: "\(Self.debugTag): cannot disconnect view while running; call stop first"),
#file,
#line
)
}
}

/// Disconnect a connected view from this controller.
///
/// May not be called directly from an effect handler running on the controller’s loop queue.
///
/// - Parameter id: the identifier received from calling `connectView(_:)`
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running or if the id is not connected
public func disconnectView(id: UUID) {
do {
try state.mutate { stoppedState in
guard stoppedState.viewConnectables[id] != nil else {
throw ErrorMessage(message: "\(Self.debugTag): invalid view connection, cannot disconnect")
}

stoppedState.viewConnectables[id] = nil
}
} catch {
MobiusHooks.errorHandler(
Expand All @@ -191,11 +216,8 @@ public final class MobiusController<Model, Event, Effect> {
do {
try state.transitionToRunning { stoppedState in
let loop = loopFactory(stoppedState.modelToStartFrom)

var disposables: [Disposable] = [loop]

if let viewConnectable = stoppedState.viewConnectable {
let viewConnection = viewConnectable.connect { [weak loop] event in
let disposables: [Disposable] = [loop] + stoppedState.viewConnectables.values.map { connectable in
let connection = connectable.connect { [weak loop] event in
guard let loop = loop else {
// This failure should not be reached under normal circumstances because it is handled by
// AsyncDispatchQueueConnectable. Stopping here means that the viewConnectable called its
Expand All @@ -205,13 +227,14 @@ public final class MobiusController<Model, Event, Effect> {

loop.unguardedDispatchEvent(event)
}
loop.addObserver(viewConnection.accept)
disposables.append(viewConnection)
loop.addObserver(connection.accept)

return connection
}

return RunningState(
loop: loop,
viewConnectable: stoppedState.viewConnectable,
viewConnectables: stoppedState.viewConnectables,
disposables: CompositeDisposable(disposables: disposables)
)
}
Expand All @@ -236,11 +259,12 @@ public final class MobiusController<Model, Event, Effect> {
public func stop() {
do {
try state.transitionToStopped { runningState in
let model = runningState.loop.latestModel

runningState.disposables.dispose()

return StoppedState(modelToStartFrom: model, viewConnectable: runningState.viewConnectable)
return StoppedState(
modelToStartFrom: runningState.loop.latestModel,
viewConnectables: runningState.viewConnectables
)
}
} catch {
MobiusHooks.errorHandler(
Expand Down
42 changes: 38 additions & 4 deletions MobiusCore/Test/MobiusControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ class MobiusControllerTests: QuickSpec {

expect(view.recorder.items).toEventually(equal(["S", "S-hey"]))
}
it("should allow multiple connections") {
let secondaryView = RecordingTestConnectable(expectedQueue: self.viewQueue)

controller.connectView(view)
controller.connectView(secondaryView)
controller.start()

expect(view.recorder.items).toEventually(equal(["S"]))
expect(secondaryView.recorder.items).toEventually(equal(["S"]))
}

context("given a connected and started loop") {
beforeEach {
Expand Down Expand Up @@ -149,10 +159,6 @@ class MobiusControllerTests: QuickSpec {
}

describe("error handling") {
it("should not allow connecting twice") {
expect(controller.connectView(view)).toNot(raiseError())
expect(controller.connectView(view)).to(raiseError())
}
it("should not allow connecting after starting") {
controller.connectView(view)
controller.start()
Expand Down Expand Up @@ -182,6 +188,15 @@ class MobiusControllerTests: QuickSpec {

expect(view.recorder.items).toEventually(equal(["S"]))
}
it("should allow disconnecting by id") {
let secondaryView = RecordingTestConnectable(expectedQueue: self.viewQueue)

let connectionID = controller.connectView(view)
let secondaryConnectionID = controller.connectView(secondaryView)

controller.disconnectView(id: connectionID)
controller.disconnectView(id: secondaryConnectionID)
}
it("should not send events to a disconnected view") {
let disconnectedView = RecordingTestConnectable(expectedQueue: self.viewQueue)
controller.connectView(disconnectedView)
Expand Down Expand Up @@ -212,6 +227,25 @@ class MobiusControllerTests: QuickSpec {
controller.disconnectView()
expect(controller.disconnectView()).to(raiseError())
}

describe("multiple view connections") {
it("should not allow disconnecting without a connection id") {
let secondaryView = RecordingTestConnectable(expectedQueue: self.viewQueue)

controller.connectView(view)
controller.connectView(secondaryView)

expect(controller.disconnectView()).to(raiseError())
}
it("should not allow disconnecting an invalid connection id") {
let secondaryView = RecordingTestConnectable(expectedQueue: self.viewQueue)

controller.connectView(view)
controller.connectView(secondaryView)

expect(controller.disconnectView(id: UUID())).to(raiseError())
}
}
}
#endif
}
Expand Down