-
Notifications
You must be signed in to change notification settings - Fork 7
Make update a protocol #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
859fc44
Factor out fx runner
gordonbrander 8b6b3a5
Provide subscribe(to:)
gordonbrander 352c43d
Receive fx on main
gordonbrander eb31568
Make updates a prototocol
gordonbrander 1e7bb83
Publish updates
gordonbrander 3cb146f
Tidy up
gordonbrander fc647a1
Change Model.Update -> UpdateType
gordonbrander 1b258c6
Toggle logger by passing one in
gordonbrander 6033c91
Add loggingEnabled flag
gordonbrander 8fe6110
Back out iOS version bump in package
gordonbrander File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| import Foundation | ||
| import Combine | ||
| import SwiftUI | ||
| import os | ||
|
|
||
| /// Fx is a publisher that publishes actions and never fails. | ||
| public typealias Fx<Action> = AnyPublisher<Action, Never> | ||
|
|
@@ -16,11 +17,15 @@ public protocol ModelProtocol: Equatable { | |
| associatedtype Action | ||
| associatedtype Environment | ||
|
|
||
| associatedtype UpdateType: UpdateProtocol where | ||
| UpdateType.Model == Self, | ||
| UpdateType.Action == Self.Action | ||
|
|
||
| static func update( | ||
| state: Self, | ||
| action: Action, | ||
| environment: Environment | ||
| ) -> Update<Self> | ||
| ) -> UpdateType | ||
| } | ||
|
|
||
| extension ModelProtocol { | ||
|
|
@@ -35,16 +40,16 @@ extension ModelProtocol { | |
| state: Self, | ||
| actions: [Action], | ||
| environment: Environment | ||
| ) -> Update<Self> { | ||
| ) -> UpdateType { | ||
| actions.reduce( | ||
| Update(state: state), | ||
| UpdateType(state: state), | ||
| { result, action in | ||
| let next = update( | ||
| state: result.state, | ||
| action: action, | ||
| environment: environment | ||
| ) | ||
| return Update( | ||
| return UpdateType( | ||
| state: next.state, | ||
| fx: result.fx.merge(with: next.fx).eraseToAnyPublisher(), | ||
| transaction: next.transaction | ||
|
|
@@ -74,70 +79,66 @@ extension ModelProtocol { | |
| state: Self, | ||
| action viewAction: ViewModel.Action, | ||
| environment: ViewModel.Environment | ||
| ) -> Update<Self> { | ||
| ) -> UpdateType { | ||
| // If getter returns nil (as in case of a list item that no longer | ||
| // exists), do nothing. | ||
| guard let inner = get(state) else { | ||
| return Update(state: state) | ||
| return UpdateType(state: state) | ||
| } | ||
| let next = ViewModel.update( | ||
| state: inner, | ||
| action: viewAction, | ||
| environment: environment | ||
| ) | ||
| return Update( | ||
| return UpdateType( | ||
| state: set(state, next.state), | ||
| fx: next.fx.map(tag).eraseToAnyPublisher(), | ||
| transaction: next.transaction | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /// Update represents a state change, together with an `Fx` publisher, | ||
| /// `UpdateProtocol` represents a state change, together with an `Fx` publisher, | ||
| /// and an optional `Transaction`. | ||
| public struct Update<Model: ModelProtocol> { | ||
| /// `State` for this update | ||
| public var state: Model | ||
| /// `Fx` for this update. | ||
| /// Default is an `Empty` publisher (no effects) | ||
| public var fx: Fx<Model.Action> | ||
| /// The transaction that should be set during this update. | ||
| /// Store uses this value to set the transaction while updating state, | ||
| /// allowing you to drive explicit animations from your update function. | ||
| /// If left `nil`, store will defer to the global transaction | ||
| /// for this state update. | ||
| /// See https://developer.apple.com/documentation/swiftui/transaction | ||
| public var transaction: Transaction? | ||
|
|
||
| public init( | ||
| public protocol UpdateProtocol { | ||
| associatedtype Model | ||
| associatedtype Action | ||
|
|
||
| init( | ||
| state: Model, | ||
| fx: Fx<Model.Action>, | ||
| fx: Fx<Action>, | ||
| transaction: Transaction? | ||
| ) { | ||
| self.state = state | ||
| self.fx = fx | ||
| self.transaction = transaction | ||
| } | ||
| ) | ||
|
|
||
| var state: Model { get set } | ||
| var fx: Fx<Action> { get set } | ||
| var transaction: Transaction? { get set } | ||
| } | ||
|
|
||
| extension UpdateProtocol { | ||
| public init(state: Model, animation: Animation? = nil) { | ||
| self.state = state | ||
| self.fx = Empty(completeImmediately: true).eraseToAnyPublisher() | ||
| self.transaction = Transaction(animation: animation) | ||
| self.init( | ||
| state: state, | ||
| fx: Empty(completeImmediately: true).eraseToAnyPublisher(), | ||
| transaction: Transaction(animation: animation) | ||
| ) | ||
| } | ||
|
|
||
| public init( | ||
| state: Model, | ||
| fx: Fx<Model.Action>, | ||
| fx: Fx<Action>, | ||
| animation: Animation? = nil | ||
| ) { | ||
| self.state = state | ||
| self.fx = fx | ||
| self.transaction = Transaction(animation: animation) | ||
| self.init( | ||
| state: state, | ||
| fx: fx, | ||
| transaction: Transaction(animation: animation) | ||
| ) | ||
| } | ||
|
|
||
| /// Merge existing fx together with new fx. | ||
| /// - Returns a new `Update` | ||
| public func mergeFx(_ fx: Fx<Model.Action>) -> Update<Model> { | ||
| public func mergeFx(_ fx: Fx<Action>) -> Self { | ||
| var this = self | ||
| this.fx = self.fx.merge(with: fx).eraseToAnyPublisher() | ||
| return this | ||
|
|
@@ -153,6 +154,34 @@ public struct Update<Model: ModelProtocol> { | |
| } | ||
| } | ||
|
|
||
| /// Concrete implementation of `UpdateProtocol`. | ||
| /// Update represents a state change, together with an `Fx` publisher, | ||
| /// and an optional `Transaction`. | ||
| public struct Update<Model: ModelProtocol>: UpdateProtocol { | ||
| /// `State` for this update | ||
| public var state: Model | ||
| /// `Fx` for this update. | ||
| /// Default is an `Empty` publisher (no effects) | ||
| public var fx: Fx<Model.Action> | ||
| /// The transaction that should be set during this update. | ||
| /// Store uses this value to set the transaction while updating state, | ||
| /// allowing you to drive explicit animations from your update function. | ||
| /// If left `nil`, store will defer to the global transaction | ||
| /// for this state update. | ||
| /// See https://developer.apple.com/documentation/swiftui/transaction | ||
| public var transaction: Transaction? | ||
|
|
||
| public init( | ||
| state: Model, | ||
| fx: Fx<Model.Action>, | ||
| transaction: Transaction? | ||
| ) { | ||
| self.state = state | ||
| self.fx = fx | ||
| self.transaction = transaction | ||
| } | ||
| } | ||
|
|
||
| /// A store is any type that can | ||
| /// - get a state | ||
| /// - send actions | ||
|
|
@@ -175,17 +204,43 @@ public protocol StoreProtocol { | |
| public final class Store<Model>: ObservableObject, StoreProtocol | ||
| where Model: ModelProtocol | ||
| { | ||
| /// Stores cancellables by ID | ||
| private(set) var cancellables: [UUID: AnyCancellable] = [:] | ||
| private var cancelTransactions: AnyCancellable? | ||
|
|
||
| /// Cancellable for fx subscription. | ||
| private var cancelFx: AnyCancellable? | ||
|
|
||
| /// Private for all actions sent to the store. | ||
| private var _actions: PassthroughSubject<Model.Action, Never> | ||
| private var _actions = PassthroughSubject<Model.Action, Never>() | ||
|
|
||
| /// Publisher for all actions sent to the store. | ||
| public var actions: AnyPublisher<Model.Action, Never> { | ||
| _actions.eraseToAnyPublisher() | ||
| } | ||
|
|
||
| /// Source publisher for batches of fx modeled as publishers. | ||
| private var _fxBatches = PassthroughSubject<Fx<Model.Action>, Never>() | ||
|
|
||
| /// `fx` represents a flat stream of actions from all fx publishers. | ||
| private var fx: AnyPublisher<Model.Action, Never> { | ||
| _fxBatches | ||
| .flatMap({ publisher in publisher }) | ||
| .receive(on: DispatchQueue.main) | ||
| .eraseToAnyPublisher() | ||
| } | ||
|
Comment on lines
+224
to
+229
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. heck yeah 🙌 |
||
|
|
||
| /// Publisher for updates performed on state | ||
| private var _updates = PassthroughSubject<Model.UpdateType, Never>() | ||
|
|
||
| /// Publisher for updates performed on state. | ||
| /// `updates` is guaranteed to fire after the state has changed. | ||
| public var updates: AnyPublisher<Model.UpdateType, Never> { | ||
| _updates.eraseToAnyPublisher() | ||
| } | ||
|
|
||
| /// Current state. | ||
| /// All writes to state happen through actions sent to `Store.send`. | ||
| @Published public private(set) var state: Model | ||
|
|
||
| /// Environment, which typically holds references to outside information, | ||
| /// such as API methods. | ||
| /// | ||
|
|
@@ -202,24 +257,47 @@ where Model: ModelProtocol | |
| /// app is stopped. | ||
| public var environment: Model.Environment | ||
|
|
||
| /// Logger to log actions sent to store. | ||
| private var logger: Logger | ||
| /// Should log? | ||
| var loggingEnabled: Bool | ||
|
|
||
| public init( | ||
| state: Model, | ||
| environment: Model.Environment | ||
| environment: Model.Environment, | ||
| loggingEnabled: Bool = false, | ||
| logger: Logger? = nil | ||
| ) { | ||
| self.state = state | ||
| self.environment = environment | ||
| self._actions = PassthroughSubject<Model.Action, Never>() | ||
| self.loggingEnabled = loggingEnabled | ||
| self.logger = logger ?? Logger( | ||
| subsystem: "ObservableStore", | ||
| category: "Store" | ||
| ) | ||
|
|
||
| self.cancelFx = self.fx | ||
| .sink(receiveValue: { [weak self] action in | ||
| self?.send(action) | ||
| }) | ||
| } | ||
|
|
||
| /// Initialize with a closure that receives environment. | ||
| /// Useful for initializing model properties from environment, and for | ||
| /// kicking off actions once at store creation. | ||
| public convenience init( | ||
| create: (Model.Environment) -> Update<Model>, | ||
| environment: Model.Environment | ||
| environment: Model.Environment, | ||
| loggingEnabled: Bool = false, | ||
| logger: Logger? = nil | ||
| ) { | ||
| let update = create(environment) | ||
| self.init(state: update.state, environment: environment) | ||
| self.init( | ||
| state: update.state, | ||
| environment: environment, | ||
| loggingEnabled: loggingEnabled, | ||
| logger: logger | ||
| ) | ||
| self.subscribe(to: update.fx) | ||
| } | ||
|
|
||
|
|
@@ -229,69 +307,40 @@ where Model: ModelProtocol | |
| public convenience init( | ||
| state: Model, | ||
| action: Model.Action, | ||
| environment: Model.Environment | ||
| environment: Model.Environment, | ||
| loggingEnabled: Bool = false, | ||
| logger: Logger? = nil | ||
| ) { | ||
| self.init(state: state, environment: environment) | ||
| self.init( | ||
| state: state, | ||
| environment: environment, | ||
| loggingEnabled: loggingEnabled, | ||
| logger: logger | ||
| ) | ||
| self.send(action) | ||
| } | ||
|
|
||
| /// Subscribe to a publisher of actions, piping them through to | ||
| /// the store. | ||
| /// | ||
| /// Holds on to the cancellable until publisher completes. | ||
| /// When publisher completes, removes cancellable. | ||
| /// Subscribe to a publisher of actions, send the actions it publishes | ||
| /// to the store. | ||
| public func subscribe(to fx: Fx<Model.Action>) { | ||
| // Create a UUID for the cancellable. | ||
| // Store cancellable in dictionary by UUID. | ||
| // Remove cancellable from dictionary upon effect completion. | ||
| // This retains the effect pipeline for as long as it takes to complete | ||
| // the effect, and then removes it, so we don't have a cancellables | ||
| // memory leak. | ||
| let id = UUID() | ||
|
|
||
| // Receive Fx on main thread. This does two important things: | ||
| // | ||
| // First, SwiftUI requires that any state mutations that would change | ||
| // views happen on the main thread. Receiving on main ensures that | ||
| // all fx-driven state transitions happen on main, even if the | ||
| // publisher is off-main-thread. | ||
| // | ||
| // Second, if we didn't schedule receive on main, it would be possible | ||
| // for publishers to complete immediately, causing receiveCompletion | ||
| // to attempt to remove the publisher from `cancellables` before | ||
| // it is added. By scheduling to receive publisher on main, | ||
| // we force publisher to complete on next tick, ensuring that it | ||
| // is always first added, then removed from `cancellables`. | ||
| let cancellable = fx | ||
| .receive( | ||
| on: DispatchQueue.main, | ||
| options: .init(qos: .default) | ||
| ) | ||
| .sink( | ||
| receiveCompletion: { [weak self] _ in | ||
| self?.cancellables.removeValue(forKey: id) | ||
| }, | ||
| receiveValue: { [weak self] action in | ||
| self?.send(action) | ||
| } | ||
| ) | ||
| self.cancellables[id] = cancellable | ||
| self._fxBatches.send(fx) | ||
| } | ||
|
|
||
| /// Send an action to the store to update state and generate effects. | ||
| /// Any effects generated are fed back into the store. | ||
| /// | ||
| /// Note: SwiftUI requires that all UI changes happen on main thread. | ||
| /// We run effects as-given, without forcing them on to main thread. | ||
| /// This means that main-thread effects will be run immediately, enabling | ||
| /// you to drive things like withAnimation via actions. | ||
| /// However it also means that publishers which run off-main-thread MUST | ||
| /// make sure that they join the main thread (e.g. with | ||
| /// `.receive(on: DispatchQueue.main)`). | ||
| /// `send(_:)` is run *synchronously*. It is up to you to guarantee it is | ||
| /// run on main thread when SwiftUI is being used. | ||
| public func send(_ action: Model.Action) { | ||
| /// Broadcast action to any outside subscribers | ||
| self._actions.send(action) | ||
| // Generate next state and effect | ||
| if loggingEnabled { | ||
| logger.log("Action: \(String(describing: action))") | ||
| } | ||
|
|
||
| // Dispatch action before state change | ||
| _actions.send(action) | ||
|
|
||
| // Create next state update | ||
| let next = Model.update( | ||
| state: self.state, | ||
| action: action, | ||
|
|
@@ -319,8 +368,12 @@ where Model: ModelProtocol | |
| self.state = next.state | ||
| } | ||
| } | ||
| // Run effect | ||
|
|
||
| // Run effects | ||
| self.subscribe(to: next.fx) | ||
|
|
||
| // Dispatch update after state change | ||
| self._updates.send(next) | ||
| } | ||
| } | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't seem to be used anywhere anymore?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Combine subscribers automatically cancel when their cancellable is released. So you have to hold on to the cancellable to keep the subscriber alive. We hold on to the cancellable within the instance so that its lifetime matches the store lifetime. The subscriber will continue receiving as long as the store instance exists.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update: after talking through, there was a confusion. The OP is in response to the fact that
cancelTransactionsis an additional cancellable property that doesn't need to be there. Followed up with fix in #43.