Skip to content

Commit

Permalink
Synthesize update(state:actions:environment) (#16)
Browse files Browse the repository at this point in the history
This PR introduces an alternative approach to composing multiple update functions. Any type that conforms to `ModelProtocol` has a `update(state:actions:environment)` static function synthesized for it. This function can be used to simulate the effect of sending multiple actions in sequence *immediately*, in effect, composing the actions.

```
update(
    state: state,
    actions: [
        .setEditor(
            text: detail.entry.body,
            saveState: detail.saveState
        ),
        .presentDetail(true),
        .requestEditorFocus(false)
    ],
    environment: environment
)
```

State is updated immediately, fx are merged, and last transaction wins.

Now that we have a way to immediately sequence actions in same state update, we no longer need to run fx on same tick. Joining on main is my preference from an API perspective because it has fewer footguns in implementation and use.

#15 caused fx to be run immediately instead of joined on main. The intent was to allow for composing multiple actions by sending up many `Just(.action)` fx. However,

- This is verbose to write, and rather "chatty".
- It also makes the store implementation less straightforward, since without joining on main, we must check if fx was completed immediately before adding to fx dictionary. Joining on main solves this problem by running the fx on next tick, after the fx has been added to dictionary.
- Additionally, it means that off-main-thread fx are required to be joined manually on main to prevent SwiftUI from complaining.

## Breaking changes

- Remove Update.pipe. Redundant now. Was never happy with it anyway. It was an inelegant way to accomplish the same thing as `update(state:actions:environment:)`.
- Revert fx to be joined on main thread. We join on main with a .default QoS, because fx should be async/never block user interaction.
  • Loading branch information
gordonbrander authored Sep 16, 2022
1 parent b773ae5 commit 5ea1c8b
Show file tree
Hide file tree
Showing 2 changed files with 159 additions and 30 deletions.
79 changes: 49 additions & 30 deletions Sources/ObservableStore/ObservableStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,37 @@ public protocol ModelProtocol: Equatable {
) -> Update<Self>
}

extension ModelProtocol {
/// Update state through a sequence of actions, merging fx.
/// - State updates happen immediately
/// - Fx are merged
/// - Last transaction wins
/// This function is useful for composing actions, or when dispatching
/// actions down to multiple child components.
/// - Returns an Update that is the result of sequencing actions
public static func update(
state: Self,
actions: [Action],
environment: Environment
) -> Update<Self> {
actions.reduce(
Update(state: state),
{ result, action in
let next = update(
state: result.state,
action: action,
environment: environment
)
return Update(
state: next.state,
fx: result.fx.merge(with: next.fx).eraseToAnyPublisher(),
transaction: next.transaction
)
}
)
}
}

/// Update represents a state change, together with an `Fx` publisher,
/// and an optional `Transaction`.
public struct Update<Model: ModelProtocol> {
Expand Down Expand Up @@ -66,27 +97,6 @@ public struct Update<Model: ModelProtocol> {
this.transaction = Transaction(animation: animation)
return this
}

/// Pipe a state through another update function.
/// Allows you to compose multiple update functions together through
/// method chaining.
///
/// - Updates state,
/// - Merges `fx`.
/// - Replaces `transaction` with new `Update` transaction.
///
/// - Returns a new `Update`
public func pipe(
_ through: (Model) -> Self
) -> Self {
let next = through(self.state)
let fx = self.fx.merge(with: next.fx).eraseToAnyPublisher()
return Update(
state: next.state,
fx: fx,
transaction: next.transaction
)
}
}

/// A store is any type that can
Expand Down Expand Up @@ -154,24 +164,33 @@ where Model: ModelProtocol
// memory leak.
let id = UUID()

// Did fx complete immediately?
// We use this flag to deal with a race condition where
// an effect can complete before it is added to cancellables,
// meaking receiveCompletion tries to clean it up before it is added.
var didComplete = false
// 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
didComplete = true
self?.cancellables.removeValue(forKey: id)
},
receiveValue: { [weak self] action in
self?.send(action)
}
)
if !didComplete {
self.cancellables[id] = cancellable
}
self.cancellables[id] = cancellable
}

/// Send an action to the store to update state and generate effects.
Expand Down
110 changes: 110 additions & 0 deletions Tests/ObservableStoreTests/UpdateActionsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
// UpdateActionsTests.swift
//
// Created by Gordon Brander on 9/14/22.
//

import XCTest
import ObservableStore
import Combine

class UpdateActionsTests: XCTestCase {
enum TestAction {
case increment
case setText(String)
case delayedText(text: String, delay: Double)
case delayedIncrement(delay: Double)
case combo
}

struct TestModel: ModelProtocol {
typealias Action = TestAction
typealias Environment = Void

var count = 0
var text = ""

static func update(
state: TestModel,
action: TestAction,
environment: Void
) -> Update<TestModel> {
switch action {
case .increment:
var model = state
model.count = model.count + 1
return Update(state: model)
.animation(.default)
case .setText(let text):
var model = state
model.text = text
return Update(state: model)
case let .delayedText(text, delay):
let fx: Fx<Action> = Just(
Action.setText(text)
)
.delay(for: .seconds(delay), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
return Update(state: state, fx: fx)
case let .delayedIncrement(delay):
let fx: Fx<Action> = Just(
Action.increment
)
.delay(for: .seconds(delay), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
return Update(state: state, fx: fx)
case .combo:
return update(
state: state,
actions: [
.increment,
.increment,
.delayedIncrement(delay: 0.02),
.delayedText(text: "Test", delay: 0.01),
.increment
],
environment: environment
)
}
}
}

func testUpdateActions() throws {
let store = Store(
state: TestModel(),
environment: ()
)
store.send(.combo)
let expectation = XCTestExpectation(
description: "Autofocus sets editor focus"
)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
XCTAssertEqual(
store.state.count,
4,
"All increments run. Fx merged."
)
XCTAssertEqual(
store.state.text,
"Test",
"Text set"
)
expectation.fulfill()
}
wait(for: [expectation], timeout: 0.2)
}

func testUpdateActionsTransaction() throws {
let next = TestModel.update(
state: TestModel(),
actions: [
.increment,
.increment,
.setText("Foo"),
.increment,
],
environment: ()
)
XCTAssertNotNil(next.transaction, "Last transaction wins")
}
}

0 comments on commit 5ea1c8b

Please sign in to comment.