Skip to content

Commit

Permalink
Unregistering of Reducers, Effects and Interceptors (#70)
Browse files Browse the repository at this point in the history
* Unregistering Effects and Interceptors

* Update TestInterceptor.swift

* Unregistering Reducers

* Restructure Store.swift

* Lint fix

* Customizable id for Reducer
  • Loading branch information
MortenGregersen authored Jun 2, 2020
1 parent 720caa4 commit 75bf205
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 35 deletions.
4 changes: 4 additions & 0 deletions Sources/Fluxor/Effects.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@ public protocol Effects {
associatedtype Environment
/// The `Effect`s to register on the `Store`.
var enabledEffects: [Effect<Environment>] { get }
/// The identifier for the `Effects`
static var id: String { get }
}

public extension Effects {
var enabledEffects: [Effect<Environment>] {
Mirror(reflecting: self).children.compactMap { $0.value as? Effect<Environment> }
}

static var id: String { .init(describing: Self.self) }
}
8 changes: 8 additions & 0 deletions Sources/Fluxor/Interceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ public protocol Interceptor {
- Parameter newState: The `State` after the `Action` was dispatched
*/
func actionDispatched(action: Action, oldState: State, newState: State)
/// The identifier for the `Interceptor`
static var id: String { get }
}

public extension Interceptor {
static var id: String { .init(describing: self) }
}

/// A type-erased `Interceptor` used to store all `Interceptor`s in an array in the `Store`.
internal struct AnyInterceptor<State>: Interceptor {
let originalId: String
private let _actionDispatched: (Action, State, State) -> Void

init<I: Interceptor>(_ interceptor: I) where I.State == State {
originalId = type(of: interceptor).id
_actionDispatched = interceptor.actionDispatched
}

Expand Down
12 changes: 9 additions & 3 deletions Sources/Fluxor/Reducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,26 @@
* MIT license, see LICENSE file for details
*/

import struct Foundation.UUID

/// A type which takes a `State` and `Action` returns a new `State`.
public struct Reducer<State> {
/// An unique identifier used when registering/unregistering the `Reducer` on the `Store`.
public let id: String
/// A pure function which takes the a `State` and an `Action` and returns a new `State`.
public let reduce: (inout State, Action) -> Void

/**
Creates a `Reducer` from a `reduce` function.

The `reduce` function is a pure function which takes the a `State` and an `Action` and returns a new `State`.

- Parameter reduce: The `reduce` function to create a `Reducer` from
- Parameter state: The `State` to mutate
- Parameter action: The `Action` dispatched
*/
public init(reduce: @escaping (_ state: inout State, _ action: Action) -> Void) {
public init(id: String = UUID().uuidString, reduce: @escaping (_ state: inout State, _ action: Action) -> Void) {
self.id = id
self.reduce = reduce
}

Expand All @@ -27,7 +32,8 @@ public struct Reducer<State> {

- Parameter reduceOns: The `ReduceOn`s which the created `Reducer` should contain
*/
public init(_ reduceOns: ReduceOn<State>...) {
public init(id: String = UUID().uuidString, _ reduceOns: ReduceOn<State>...) {
self.id = id
self.reduce = { state, action in
reduceOns.forEach { $0.reduce(&state, action) }
}
Expand Down
104 changes: 86 additions & 18 deletions Sources/Fluxor/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ open class Store<State: Encodable, Environment>: ObservableObject {
private let actions = PassthroughSubject<Action, Never>()
private let environment: Environment
private var reducers = [KeyedReducer<State>]()
private var effectCancellables = Set<AnyCancellable>()
private var effects = [String: [AnyCancellable]]()
private var interceptors = [AnyInterceptor<State>]()

// MARK: - Initialization

/**
Initializes the `Store` with an initial `State`, an `Environment` and eventually `Reducer`s.

Expand All @@ -47,6 +49,8 @@ open class Store<State: Encodable, Environment>: ObservableObject {
reducers.forEach(register(reducer:))
}

// MARK: - Dispatching

/**
Dispatches an `Action` and creates a new `State` by running the current `State` and the `Action`
through all registered `Reducer`s.
Expand All @@ -65,6 +69,8 @@ open class Store<State: Encodable, Environment>: ObservableObject {
actions.send(action)
}

// MARK: - Reducers

/**
Registers the given `Reducer`. The `Reducer` will be run for all subsequent actions.

Expand All @@ -84,13 +90,24 @@ open class Store<State: Encodable, Environment>: ObservableObject {
reducers.append(KeyedReducer(keyPath: keyPath, reducer: reducer))
}

/**
Unregisters the given `Reducer`. The `Reducer` will no longer be run when `Action`s are dispatched.

- Parameter reducer: The `Reducer` to unregister
*/
public func unregister<SomeState>(reducer: Reducer<SomeState>) {
reducers.removeAll { $0.id == reducer.id }
}

// MARK: - Effects

/**
Registers the given `Effects`. The `Effects` will receive all subsequent actions.

- Parameter effects: The `Effects` to register
*/
public func register<E: Effects>(effects: E) where E.Environment == Environment {
register(effects: effects.enabledEffects)
self.effects[type(of: effects).id] = createCancellables(for: effects.enabledEffects)
}

/**
Expand All @@ -105,36 +122,47 @@ open class Store<State: Encodable, Environment>: ObservableObject {
/**
Registers the given `Effect`. The `Effect` will receive all subsequent actions.

Only `Effect`s registered from a type conforming to `Effects` can be unregistered.

- Parameter effect: The `Effect` to register
*/
public func register(effect: Effect<Environment>) {
let cancellable: AnyCancellable
switch effect {
case .dispatchingOne(let effectCreator):
cancellable = effectCreator(actions.eraseToAnyPublisher(), environment)
.receive(on: DispatchQueue.main)
.sink(receiveValue: self.dispatch(action:))
case .dispatchingMultiple(let effectCreator):
cancellable = effectCreator(actions.eraseToAnyPublisher(), environment)
.receive(on: DispatchQueue.main)
.sink { $0.forEach(self.dispatch(action:)) }
case .nonDispatching(let effectCreator):
cancellable = effectCreator(actions.eraseToAnyPublisher(), environment)
}
cancellable.store(in: &effectCancellables)
self.effects["*"] = (self.effects["*"] ?? []) + [createCancellable(for: effect)]
}

/**
Registers the given `Interceptor`. The `Interceptor` will receive all subsequent `Action`s and state changes.
Unregisters the given `Effects`. The `Effects` will no longer receive any actions.

- Parameter effects: The `Effects` to register
*/
public func unregisterEffects<E: Effects>(ofType effects: E.Type) where E.Environment == Environment {
self.effects.removeValue(forKey: effects.id) // An AnyCancellable instance calls cancel() when deinitialized
}

The associated type `State` on the `Interceptor` must match the generic `State` on the `Store`.
// MARK: - Interceptors

/**
Registers the given `Interceptor`. The `Interceptor` will receive all subsequent `Action`s and state changes.

- Parameter interceptor: The `Interceptor` to register
*/
public func register<I: Interceptor>(interceptor: I) where I.State == State {
interceptors.append(AnyInterceptor(interceptor))
}

/**
Unregisters all registered `Interceptor`s of the given type.
The `Interceptor`s will no longer receive any `Action`s or state changes.

- Parameter interceptor: The type of`Interceptor` to unregister
*/

public func unregisterInterceptors<I: Interceptor>(ofType interceptor: I.Type) where I.State == State {
interceptors.removeAll { $0.originalId == interceptor.id }
}

// MARK: - Selecting

/**
Creates a `Publisher` for a `Selector`.

Expand All @@ -156,6 +184,8 @@ open class Store<State: Encodable, Environment>: ObservableObject {
}
}

// MARK: - Void Environment

public extension Store where Environment == Void {
/**
Initializes the `Store` with an initial `State` and eventually `Reducer`s.
Expand All @@ -170,10 +200,48 @@ public extension Store where Environment == Void {
}
}

// MARK: - Private

extension Store {
/**
Creates `Cancellable`s for the given `Effect`s.

- Parameter effects: The `Effect`s to create `Cancellable`s for
- Returns: The `Cancellable`s for the given `Effect`s
*/
private func createCancellables(for effects: [Effect<Environment>]) -> [AnyCancellable] {
return effects.map(createCancellable(for:))
}

/**
Creates `Cancellable` for the given `Effect`.

- Parameter effect: The `Effect` to create `Cancellable` for
- Returns: The `Cancellable` for the given `Effect`
*/
private func createCancellable(for effect: Effect<Environment>) -> AnyCancellable {
switch effect {
case .dispatchingOne(let effectCreator):
return effectCreator(actions.eraseToAnyPublisher(), environment)
.receive(on: DispatchQueue.main)
.sink(receiveValue: self.dispatch(action:))
case .dispatchingMultiple(let effectCreator):
return effectCreator(actions.eraseToAnyPublisher(), environment)
.receive(on: DispatchQueue.main)
.sink { $0.forEach(self.dispatch(action:)) }
case .nonDispatching(let effectCreator):
return effectCreator(actions.eraseToAnyPublisher(), environment)
}
}
}

/// A wrapper for a `Reducer` for a specific `KeyPath`.
private struct KeyedReducer<State> {
let id: String
let reduce: (inout State, Action) -> Void

init<Substate>(keyPath: WritableKeyPath<State, Substate>, reducer: Reducer<Substate>) {
self.id = reducer.id
self.reduce = { state, action in
var substate = state[keyPath: keyPath]
reducer.reduce(&substate, action)
Expand Down
8 changes: 8 additions & 0 deletions Sources/FluxorTestSupport/TestInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ public class TestInterceptor<State>: Interceptor {
stateChanges.append((action, oldState, newState))
}

/**
Waits for the expected number of `Action`s to be intercepted.
If the expected number of `Action`s are not intercepted before the timout an error is thrown.

- Parameter expectedNumberOfActions: The number of `Action`s to wait for
- Parameter timeout: The waiting time before failing (in seconds)
*/
public func waitForActions(expectedNumberOfActions: Int, timeout: TimeInterval = 1) throws {
guard stateChanges.count < expectedNumberOfActions else { return }

Expand All @@ -41,6 +48,7 @@ public class TestInterceptor<State>: Interceptor {
throw WaitingError.expectedCountNotReached(message: errorMessage)
}

/// Errors waiting for intercepted `Action`s
public enum WaitingError: Error {
case expectedCountNotReached(message: String)
}
Expand Down
Loading

0 comments on commit 75bf205

Please sign in to comment.