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
249 changes: 151 additions & 98 deletions Sources/ObservableStore/ObservableStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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?
Copy link
Copy Markdown

@bfollington bfollington Nov 30, 2023

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?

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.

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.

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.

Update: after talking through, there was a confusion. The OP is in response to the fact that cancelTransactions is an additional cancellable property that doesn't need to be there. Followed up with fix in #43.


/// 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
Copy link
Copy Markdown

@bfollington bfollington Nov 30, 2023

Choose a reason for hiding this comment

The 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.
///
Expand All @@ -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)
}

Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion Tests/ObservableStoreTests/BindingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ final class BindingTests: XCTestCase {

view.text = "Foo"
view.text = "Bar"

XCTAssertEqual(
store.state.text,
"Bar"
Expand Down
Loading