mirror of
https://github.com/pointfreeco/swift-composable-architecture.git
synced 2025-12-20 09:11:33 +01:00
* Clarify assertion failure for incorrect send usage * Update ViewStore.swift * Update Sources/ComposableArchitecture/Store.swift Co-authored-by: Dante Broggi <34220985+Dante-Broggi@users.noreply.github.com> * Update Sources/ComposableArchitecture/Store.swift Co-authored-by: Stephen Celis <stephen@stephencelis.com> Co-authored-by: Dante Broggi <34220985+Dante-Broggi@users.noreply.github.com> Co-authored-by: Stephen Celis <stephen@stephencelis.com>
235 lines
8.6 KiB
Swift
235 lines
8.6 KiB
Swift
import Combine
|
|
import Foundation
|
|
|
|
/// A store represents the runtime that powers the application. It is the object that you will pass
|
|
/// around to views that need to interact with the application.
|
|
///
|
|
/// You will typically construct a single one of these at the root of your application, and then use
|
|
/// the `scope` method to derive more focused stores that can be passed to subviews.
|
|
public final class Store<State, Action> {
|
|
@Published private(set) var state: State
|
|
var effectCancellables: [UUID: AnyCancellable] = [:]
|
|
private var isSending = false
|
|
private var parentCancellable: AnyCancellable?
|
|
private let reducer: (inout State, Action) -> Effect<Action, Never>
|
|
private var synchronousActionsToSend: [Action] = []
|
|
|
|
/// Initializes a store from an initial state, a reducer, and an environment.
|
|
///
|
|
/// - Parameters:
|
|
/// - initialState: The state to start the application in.
|
|
/// - reducer: The reducer that powers the business logic of the application.
|
|
/// - environment: The environment of dependencies for the application.
|
|
public convenience init<Environment>(
|
|
initialState: State,
|
|
reducer: Reducer<State, Action, Environment>,
|
|
environment: Environment
|
|
) {
|
|
self.init(
|
|
initialState: initialState,
|
|
reducer: { reducer.run(&$0, $1, environment) }
|
|
)
|
|
}
|
|
|
|
/// Scopes the store to one that exposes local state and actions.
|
|
///
|
|
/// This can be useful for deriving new stores to hand to child views in an application. For
|
|
/// example:
|
|
///
|
|
/// // Application state made from local states.
|
|
/// struct AppState { var login: LoginState, ... }
|
|
/// struct AppAction { case login(LoginAction), ... }
|
|
///
|
|
/// // A store that runs the entire application.
|
|
/// let store = Store(initialState: AppState(), reducer: appReducer, environment: ())
|
|
///
|
|
/// // Construct a login view by scoping the store to one that works with only login domain.
|
|
/// let loginView = LoginView(
|
|
/// store: store.scope(
|
|
/// state: { $0.login },
|
|
/// action: { AppAction.login($0) }
|
|
/// )
|
|
/// )
|
|
///
|
|
/// - Parameters:
|
|
/// - toLocalState: A function that transforms `State` into `LocalState`.
|
|
/// - fromLocalAction: A function that transforms `LocalAction` into `Action`.
|
|
/// - Returns: A new store with its domain (state and action) transformed.
|
|
public func scope<LocalState, LocalAction>(
|
|
state toLocalState: @escaping (State) -> LocalState,
|
|
action fromLocalAction: @escaping (LocalAction) -> Action
|
|
) -> Store<LocalState, LocalAction> {
|
|
let localStore = Store<LocalState, LocalAction>(
|
|
initialState: toLocalState(self.state),
|
|
reducer: { localState, localAction in
|
|
self.send(fromLocalAction(localAction))
|
|
localState = toLocalState(self.state)
|
|
return .none
|
|
}
|
|
)
|
|
localStore.parentCancellable = self.$state
|
|
.sink { [weak localStore] newValue in localStore?.state = toLocalState(newValue) }
|
|
return localStore
|
|
}
|
|
|
|
/// Scopes the store to one that exposes local state.
|
|
///
|
|
/// - Parameter toLocalState: A function that transforms `State` into `LocalState`.
|
|
/// - Returns: A new store with its domain (state and action) transformed.
|
|
public func scope<LocalState>(
|
|
state toLocalState: @escaping (State) -> LocalState
|
|
) -> Store<LocalState, Action> {
|
|
self.scope(state: toLocalState, action: { $0 })
|
|
}
|
|
|
|
/// Scopes the store to a publisher of stores of more local state and local actions.
|
|
///
|
|
/// - Parameters:
|
|
/// - toLocalState: A function that transforms a publisher of `State` into a publisher of
|
|
/// `LocalState`.
|
|
/// - fromLocalAction: A function that transforms `LocalAction` into `Action`.
|
|
/// - Returns: A publisher of stores with its domain (state and action) transformed.
|
|
public func scope<P: Publisher, LocalState, LocalAction>(
|
|
state toLocalState: @escaping (AnyPublisher<State, Never>) -> P,
|
|
action fromLocalAction: @escaping (LocalAction) -> Action
|
|
) -> AnyPublisher<Store<LocalState, LocalAction>, Never>
|
|
where P.Output == LocalState, P.Failure == Never {
|
|
|
|
func extractLocalState(_ state: State) -> LocalState? {
|
|
var localState: LocalState?
|
|
_ = toLocalState(Just(state).eraseToAnyPublisher())
|
|
.sink { localState = $0 }
|
|
return localState
|
|
}
|
|
|
|
return toLocalState(self.$state.eraseToAnyPublisher())
|
|
.map { localState in
|
|
let localStore = Store<LocalState, LocalAction>(
|
|
initialState: localState,
|
|
reducer: { localState, localAction in
|
|
self.send(fromLocalAction(localAction))
|
|
localState = extractLocalState(self.state) ?? localState
|
|
return .none
|
|
})
|
|
|
|
localStore.parentCancellable = self.$state
|
|
.sink { [weak localStore] state in
|
|
guard let localStore = localStore else { return }
|
|
localStore.state = extractLocalState(state) ?? localStore.state
|
|
}
|
|
return localStore
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
/// Scopes the store to a publisher of stores of more local state and local actions.
|
|
///
|
|
/// - Parameter toLocalState: A function that transforms a publisher of `State` into a publisher
|
|
/// of `LocalState`.
|
|
/// - Returns: A publisher of stores with its domain (state and action)
|
|
/// transformed.
|
|
public func scope<P: Publisher, LocalState>(
|
|
state toLocalState: @escaping (AnyPublisher<State, Never>) -> P
|
|
) -> AnyPublisher<Store<LocalState, Action>, Never>
|
|
where P.Output == LocalState, P.Failure == Never {
|
|
self.scope(state: toLocalState, action: { $0 })
|
|
}
|
|
|
|
func send(_ action: Action) {
|
|
if self.isSending {
|
|
assertionFailure(
|
|
"""
|
|
The store was sent an action while it was already processing another action. This can \
|
|
happen for a few reasons:
|
|
|
|
* The store was sent an action recursively. This can occur when you run an effect directly \
|
|
in the reducer, rather than returning it from the reducer. Check the stack (⌘7) to find \
|
|
frames corresponding to one of your reducers. That code should be refactored to not invoke \
|
|
the effect directly.
|
|
|
|
* The store has been sent actions from multiple threads. The `send` method is not \
|
|
thread-safe, and should only ever be used from a single thread (typically the main thread).
|
|
Instead of calling `send` from multiple threads you should use effects to process expensive \
|
|
computations on background threads so that it can be fed back into the store.
|
|
"""
|
|
)
|
|
}
|
|
self.isSending = true
|
|
let effect = self.reducer(&self.state, action)
|
|
self.isSending = false
|
|
|
|
var didComplete = false
|
|
let uuid = UUID()
|
|
|
|
var isProcessingEffects = true
|
|
let effectCancellable = effect.sink(
|
|
receiveCompletion: { [weak self] _ in
|
|
didComplete = true
|
|
self?.effectCancellables[uuid] = nil
|
|
},
|
|
receiveValue: { [weak self] action in
|
|
if isProcessingEffects {
|
|
self?.synchronousActionsToSend.append(action)
|
|
} else {
|
|
self?.send(action)
|
|
}
|
|
}
|
|
)
|
|
isProcessingEffects = false
|
|
|
|
if !didComplete {
|
|
self.effectCancellables[uuid] = effectCancellable
|
|
}
|
|
|
|
while !self.synchronousActionsToSend.isEmpty {
|
|
let action = self.synchronousActionsToSend.removeFirst()
|
|
self.send(action)
|
|
}
|
|
}
|
|
|
|
/// Returns a "stateless" store by erasing state to `Void`.
|
|
public var stateless: Store<Void, Action> {
|
|
self.scope(state: { _ in () })
|
|
}
|
|
|
|
/// Returns an "actionless" store by erasing action to `Never`.
|
|
public var actionless: Store<State, Never> {
|
|
func absurd<A>(_ never: Never) -> A {}
|
|
return self.scope(state: { $0 }, action: absurd)
|
|
}
|
|
|
|
private init(
|
|
initialState: State,
|
|
reducer: @escaping (inout State, Action) -> Effect<Action, Never>
|
|
) {
|
|
self.reducer = reducer
|
|
self.state = initialState
|
|
}
|
|
}
|
|
|
|
/// A publisher of store state.
|
|
@dynamicMemberLookup
|
|
public struct StorePublisher<State>: Publisher {
|
|
public typealias Output = State
|
|
public typealias Failure = Never
|
|
|
|
public let upstream: AnyPublisher<State, Never>
|
|
|
|
public func receive<S>(subscriber: S)
|
|
where S: Subscriber, Failure == S.Failure, Output == S.Input {
|
|
self.upstream.receive(subscriber: subscriber)
|
|
}
|
|
|
|
init<P>(_ upstream: P) where P: Publisher, Failure == P.Failure, Output == P.Output {
|
|
self.upstream = upstream.eraseToAnyPublisher()
|
|
}
|
|
|
|
/// Returns the resulting publisher of a given key path.
|
|
public subscript<LocalState>(
|
|
dynamicMember keyPath: KeyPath<State, LocalState>
|
|
) -> StorePublisher<LocalState>
|
|
where LocalState: Equatable {
|
|
.init(self.upstream.map(keyPath).removeDuplicates())
|
|
}
|
|
}
|