From 332458eb7e361e150baba221c20a42bf8bd01a40 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Jan 2023 10:00:46 -0500 Subject: [PATCH] Introduce KeyedCursorProtocol, remove ViewStore in favor of forward (#19) Fixes #18. This PR sketches out one potential solution to #18. It refactors our approach to sub-components by decomplecting action sending from state getting. - Removes `ViewStore` - Introduces `Address.forward(send:tag:)` which gives us an easy way to create tagged `send` functions. This solves one part of what ViewStore was solving. - Introduces `Binding(get:send:tag:)` which gives us the binding equivalent to `Address.forward` - Introduces `KeyedCursorProtocol` which offers an alternative cursor for subcomponents that need to be looked up within dynamic lists. This refactor is in response to the awkwardness of the `ViewStore/Cursor` paradigm for components that are part of a dynamic list. Even if we had created a keyed cursor initializer for ViewStore, it necessarily would have had to hold an optional (nillable) state. This is because ViewStore lookup was dynamic, and this trips up the lifetime typechecking around the model. In practice, a view would not exist if its model did not exist, but this is not a typesafe guarantee for dynamic list lookups. Anyway, the whole paradigm of looking up child from parent dynamically is a bit odd for list items. In SwiftUI the typical approach is to ForEach, and then pass the model data down as a static property to the view. This guarantees type safety, since a view holds its own copy of the data. What if we could do something more like that? The approach in this PR leans into this approach. State can be passed to sub-components as plain old properties. `Address.forward` can be used to create view-local send functions that you can pass down to sub-views. `Binding` gets a similar form. In both cases, we can use a closure to capture additional parent-scoped state, such as an ID for lookup within the parent model. Cursor sticks around, but mostly as a convenient way to create update functions for sub-components. We also introduce `KeyedCursorProtocol` which offers a keyed equivalent for dynamic lookup. ## Usage Sub-components become more "vanilla", just using bare properties and closures. ```swift struct ParentView: View { @StateObject = Store( AppModel(), AppEnvironment() ) var body: some View { ChildModel( state: store.state.child, send: Address.forward( send: store.send, tag: ParentChildCursor.tag ) ) } } struct ChildView: View { var state: ChildModel var send: (ChildAction) -> Void var body: some View { Button(state.text) { send(.activate) } } } ``` ## Prior art This approach is inspired by Reflex: - Forward https://github.com/mozilla/reflex/blob/c5e75e98bc601e2315b6d43e5e347263cf67359e/src/signal.js#L5 - Cursor https://github.com/browserhtml/browserhtml/blob/master/src/Common/Cursor.js --- README.md | 82 ++++---- Sources/ObservableStore/ObservableStore.swift | 151 ++++++++------ Tests/ObservableStoreTests/BindingTests.swift | 71 +++++++ ...ests.swift => ComponentMappingTests.swift} | 188 +++++++++++------- .../ObservableStoreTests.swift | 79 ++++---- 5 files changed, 350 insertions(+), 221 deletions(-) create mode 100644 Tests/ObservableStoreTests/BindingTests.swift rename Tests/ObservableStoreTests/{ViewStoreTests.swift => ComponentMappingTests.swift} (50%) diff --git a/README.md b/README.md index c4b48f5..4b9b706 100644 --- a/README.md +++ b/README.md @@ -191,40 +191,27 @@ Button("Set color to red") { ## Bindings -`Binding(store:get:tag:)` lets you create a [binding](https://developer.apple.com/documentation/swiftui/binding) that represents some part of the store state. The `get` closure reads the state into a value, and the `tag` closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, but changes state only through deterministic updates. +`Binding(get:send:tag:)` lets you create a [binding](https://developer.apple.com/documentation/swiftui/binding) that represents some part of the store state. The `get` closure reads the state into a value, and the `tag` closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, but changes state only through deterministic updates. ```swift TextField( "Username" text: Binding( - store: store, - get: { state in state.username }, + get: { store.state.username }, + send: store.send, tag: { username in .setUsername(username) } ) ) ``` -Or, shorthand: - -```swift -TextField( - "Username" - text: Binding( - store: store, - get: \.username, - tag: .setUsername - ) -) -``` - Bottom line, because Store is just an ordinary [ObservableObject](https://developer.apple.com/documentation/combine/observableobject), and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, [@Binding](https://developer.apple.com/documentation/swiftui/binding), [@ObservedObject](https://developer.apple.com/documentation/swiftui/observedobject), [@StateObject](https://developer.apple.com/documentation/swiftui/stateobject) and [@EnvironmentObject](https://developer.apple.com/documentation/swiftui/environmentobject) all work as you would expect. -## ViewStore +## Scoping store for child components -ViewStore lets you create component-scoped stores from a shared root store. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store. You can think of ViewStore as like a binding, except that it exposes the same StoreProtocol API that Store does. +We can also create component-scoped state and send callbacks from a shared root store. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store. -Imagine we have a stand-alone child component that looks something like this: +Imagine we have a vanilla SWiftUI child view that looks something like this: ```swift enum ChildAction { @@ -249,11 +236,12 @@ struct ChildModel: ModelProtocol { } struct ChildView: View { - var store: ViewStore + var state: ChildModel + var send: (ChildAction) -> Void var body: some View { VStack { - Text("Count \(store.state.count)") + Text("Count \(state.count)") Button( "Increment", action: { @@ -265,12 +253,39 @@ struct ChildView: View { } ``` -Now we want to integrate this child component with a parent component. To do this, we can create a ViewStore from the parent's root store. We just need to specify a way to map from this child component's state and actions to the root store state and actions. This is where `CursorProtocol` comes in. It defines three things: +Now we want to integrate this child component with a parent component. To do this, we just need to specify a way to map from this child component's state and actions to the root store state and actions. First, we pass down the part of the state the child uses. Then we create a scoped `send` function using `Address.forward`. It maps child actions to parent actions using a `tag` closure we provide. + +```swift +struct ContentView: View { + @StateObject private var store: Store + + var body: some View { + ChildView( + state: store.state.child, + send: Address.forward( + send: store.send, + tag: { + switch action { + default: + return .child(action) + } + } + ) + ) + } +} +``` + +Now we just need to integrate our child component's update function with the root update function. This is where `CursorProtocol` comes in. + +It defines three things: - A way to `get` a local state from the root state - A way to `set` a local state on a root state - A way to `tag` a local action so it becomes a root action +...and synthesizes an `update` function that automatically maps child state and actions to parent state and actions. + ```swift struct AppChildCursor: CursorProtocol { /// Get child state from parent @@ -295,27 +310,6 @@ struct AppChildCursor: CursorProtocol { } ``` -...This gives us everything we need to map from a local scope to the global store. Now we can create a scoped ViewStore from the shared app store and pass it down to our ChildView. - -```swift -struct ContentView: View { - @ObservedObject private var store: Store - - var body: some View { - ChildView( - store: ViewStore( - store: store, - cursor: AppChildCursor.self - ) - ) - } -} -``` - -ViewStores can also be created from other ViewStores, allowing for hierarchical nesting of components. - -Now we just need to integrate our child component's update function with the root update function. Cursors gives us a handy shortcut by synthesizing an `update` function that automatically maps child state and actions to parent state and actions. - ```swift enum AppAction { case child(ChildAction) @@ -342,5 +336,3 @@ struct AppModel: ModelProtocol { ``` This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways. - -That's it! You can get state and send actions from the ViewStore, just like any other store, and it will translate local state changes and fx into app-level state changes and fx. Using ViewStore you can compose an app from multiple stand-alone components that each describe their own domain model and update logic. diff --git a/Sources/ObservableStore/ObservableStore.swift b/Sources/ObservableStore/ObservableStore.swift index 1a26569..f3bc9d3 100644 --- a/Sources/ObservableStore/ObservableStore.swift +++ b/Sources/ObservableStore/ObservableStore.swift @@ -258,6 +258,18 @@ where Model: ModelProtocol } } +public struct Address { + /// Forward transform an address (send function) into a local address. + /// View-scoped actions are tagged using `tag` before being forwarded to + /// `send.` + public static func forward( + send: @escaping (Action) -> Void, + tag: @escaping (ViewAction) -> Action + ) -> (ViewAction) -> Void { + { viewAction in send(tag(viewAction)) } + } +} + /// A cursor provides a complete description of how to map from one component /// domain to another. public protocol CursorProtocol { @@ -302,84 +314,99 @@ extension CursorProtocol { } } -/// ViewStore is a local projection of a Store that can be passed down to -/// a child view. -// NOTE: ViewStore works like Binding. It reads state at runtime using a -// getter closure that you provide. It is important that we -// read the state via a closure, like Binding does, rather than -// storing the literal value as a property of the instance. -// If you store the literal value as a property, you will have "liveness" -// issues with the data in views, especially around things like text editors. -// Letters entered out of order, old states showing up, etc. -// I suspect this has something to do with either the guts of SwiftUI or the -// guts of UIViewRepresentable. -// 2022-06-12 Gordon Brander -public struct ViewStore: StoreProtocol { - private let _get: () -> ViewModel - private let _send: (ViewModel.Action) -> Void +public protocol KeyedCursorProtocol { + associatedtype Key + associatedtype Model: ModelProtocol + associatedtype ViewModel: ModelProtocol - /// Initialize a ViewStore using a get and send closure. - public init( - get: @escaping () -> ViewModel, - send: @escaping (ViewModel.Action) -> Void - ) { - self._get = get - self._send = send - } + /// Get an inner state from an outer state + static func get(state: Model, key: Key) -> ViewModel? - /// Get current state - public var state: ViewModel { self._get() } + /// Set an inner state on an outer state, returning an outer state + static func set(state: Model, inner: ViewModel, key: Key) -> Model - /// Send an action - public func send(_ action: ViewModel.Action) { - self._send(action) - } + /// Tag an inner action, transforming it into an outer action + static func tag(action: ViewModel.Action, key: Key) -> Model.Action } -extension ViewStore { - /// Initialize a ViewStore from a store of some type, and a cursor. - /// - Store can be any type conforming to `StoreProtocol` - /// - Cursor can be any type conforming to `CursorProtocol` - public init(store: Store, cursor: Cursor.Type) - where - Store: StoreProtocol, - Cursor: CursorProtocol, - Store.Model == Cursor.Model, - ViewModel == Cursor.ViewModel - { - self.init( - get: { Cursor.get(state: store.state) }, - send: { action in store.send(Cursor.tag(action)) } +extension KeyedCursorProtocol { + /// Update an inner state within an outer state through a keyed cursor. + /// This cursor type is useful when looking up children in dynamic lists + /// such as arrays or dictionaries. + /// + /// - `state` the outer state + /// - `action` the inner action + /// - `environment` the environment for the update function + /// - `key` a key uniquely representing this model in the parent domain + /// - Returns an update for a new outer state or nil + public static func update( + state: Model, + action viewAction: ViewModel.Action, + environment viewEnvironment: ViewModel.Environment, + key: Key + ) -> Update? { + guard let viewModel = get(state: state, key: key) else { + return nil + } + let next = ViewModel.update( + state: viewModel, + action: viewAction, + environment: viewEnvironment + ) + return Update( + state: set(state: state, inner: next.state, key: key), + fx: next.fx + .map({ viewAction in Self.tag(action: viewAction, key: key) }) + .eraseToAnyPublisher(), + transaction: next.transaction ) } -} -extension ViewStore { - /// Create a ViewStore for a constant state that swallows actions. - /// Convenience for view previews. - public static func constant( - state: ViewModel - ) -> ViewStore { - ViewStore( - get: { state }, - send: { action in } - ) + /// Update an inner state within an outer state through a keyed cursor. + /// This cursor type is useful when looking up children in dynamic lists + /// such as arrays or dictionaries. + /// + /// This version of update always returns an `Update`. If the child model + /// cannot be found at key, then it returns an update for the same state + /// (noop), effectively ignoring the action. + /// + /// - `state` the outer state + /// - `action` the inner action + /// - `environment` the environment for the update function + /// - `key` a key uniquely representing this model in the parent domain + /// - Returns an update for a new outer state or nil + public static func update( + state: Model, + action viewAction: ViewModel.Action, + environment viewEnvironment: ViewModel.Environment, + key: Key + ) -> Update { + guard let next = update( + state: state, + action: viewAction, + environment: viewEnvironment, + key: key + ) else { + return Update(state: state) + } + return next } } extension Binding { /// Initialize a Binding from a store. - /// - `get` reads the store state to a binding value. - /// - `tag` transforms the value into an action. + /// - `get` reads the binding value. + /// - `send` sends actions to some address. + /// - `tag` tags the value, turning it into an action for `send` /// - Returns a binding suitable for use in a vanilla SwiftUI view. - public init( - store: Store, - get: @escaping (Store.Model) -> Value, - tag: @escaping (Value) -> Store.Model.Action + public init( + get: @escaping () -> Value, + send: @escaping (Action) -> Void, + tag: @escaping (Value) -> Action ) { self.init( - get: { get(store.state) }, - set: { value in store.send(tag(value)) } + get: get, + set: { value in send(tag(value)) } ) } } diff --git a/Tests/ObservableStoreTests/BindingTests.swift b/Tests/ObservableStoreTests/BindingTests.swift new file mode 100644 index 0000000..b364a3c --- /dev/null +++ b/Tests/ObservableStoreTests/BindingTests.swift @@ -0,0 +1,71 @@ +// +// BindingTests.swift +// +// +// Created by Gordon Brander on 9/21/22. +// + +import XCTest +import SwiftUI +@testable import ObservableStore + +final class BindingTests: XCTestCase { + enum Action: Hashable { + case setText(String) + } + + struct Model: ModelProtocol { + var text = "" + var edits: Int = 0 + + static func update( + state: Model, + action: Action, + environment: Void + ) -> Update { + switch action { + case .setText(let text): + var model = state + model.text = text + model.edits = model.edits + 1 + return Update(state: model) + } + } + } + + struct SimpleView: View { + @Binding var text: String + + var body: some View { + Text(text) + } + } + + /// Test creating binding for an address + func testBinding() throws { + let store = Store( + state: Model(), + environment: () + ) + + let binding = Binding( + get: { store.state.text }, + send: store.send, + tag: Action.setText + ) + + let view = SimpleView(text: binding) + + view.text = "Foo" + view.text = "Bar" + + XCTAssertEqual( + store.state.text, + "Bar" + ) + XCTAssertEqual( + store.state.edits, + 2 + ) + } +} diff --git a/Tests/ObservableStoreTests/ViewStoreTests.swift b/Tests/ObservableStoreTests/ComponentMappingTests.swift similarity index 50% rename from Tests/ObservableStoreTests/ViewStoreTests.swift rename to Tests/ObservableStoreTests/ComponentMappingTests.swift index c93a380..6461fe3 100644 --- a/Tests/ObservableStoreTests/ViewStoreTests.swift +++ b/Tests/ObservableStoreTests/ComponentMappingTests.swift @@ -10,16 +10,18 @@ import Combine import SwiftUI @testable import ObservableStore -class TestsViewStore: XCTestCase { +class ComponentMappingTests: XCTestCase { enum ParentAction: Hashable { case child(ChildAction) + case keyedChild(action: ChildAction, key: String) case setText(String) } - + struct ParentModel: ModelProtocol { var child = ChildModel(text: "") + var keyedChildren: [String: ChildModel] = [:] var edits: Int = 0 - + static func update( state: ParentModel, action: ParentAction, @@ -32,6 +34,13 @@ class TestsViewStore: XCTestCase { action: action, environment: () ) + case let .keyedChild(action, key): + return KeyedParentChildCursor.update( + state: state, + action: action, + environment: (), + key: key + ) case .setText(let text): var next = ParentChildCursor.update( state: state, @@ -43,14 +52,14 @@ class TestsViewStore: XCTestCase { } } } - + enum ChildAction: Hashable { case setText(String) } - + struct ChildModel: ModelProtocol { var text: String - + static func update( state: ChildModel, action: ChildAction, @@ -65,18 +74,18 @@ class TestsViewStore: XCTestCase { } } } - + struct ParentChildCursor: CursorProtocol { static func get(state: ParentModel) -> ChildModel { state.child } - + static func set(state: ParentModel, inner: ChildModel) -> ParentModel { var model = state model.child = inner return model } - + static func tag(_ action: ChildAction) -> ParentAction { switch action { case .setText(let string): @@ -84,32 +93,41 @@ class TestsViewStore: XCTestCase { } } } - - struct SimpleView: View { - @Binding var text: String - - var body: some View { - Text(text) + + struct KeyedParentChildCursor: KeyedCursorProtocol { + static func get(state: ParentModel, key: String) -> ChildModel? { + state.keyedChildren[key] + } + + static func set( + state: ParentModel, + inner: ChildModel, + key: String + ) -> ParentModel { + var model = state + model.keyedChildren[key] = inner + return model + } + + static func tag(action: ChildAction, key: String) -> ParentAction { + .keyedChild(action: action, key: key) } } - - func testViewStoreCursor() throws { + + func testForward() throws { let store = Store( state: ParentModel(), environment: () ) - - let viewStore: ViewStore = ViewStore( - store: store, - cursor: ParentChildCursor.self - ) - - viewStore.send(.setText("Foo")) - viewStore.send(.setText("Bar")) - XCTAssertEqual( - viewStore.state.text, - "Bar" + + let send = Address.forward( + send: store.send, + tag: ParentChildCursor.tag ) + + send(.setText("Foo")) + send(.setText("Bar")) + XCTAssertEqual( store.state.child.text, "Bar" @@ -119,77 +137,93 @@ class TestsViewStore: XCTestCase { 2 ) } - - func testViewStoreGetTag() throws { + + func testKeyedCursorUpdate() throws { let store = Store( - state: ParentModel(), + state: ParentModel( + keyedChildren: [ + "a": ChildModel(text: "A"), + "b": ChildModel(text: "B"), + "c": ChildModel(text: "C"), + ] + ), environment: () ) - - let viewStore: ViewStore = ViewStore( - store: store, - cursor: ParentChildCursor.self - ) - - viewStore.send(.setText("Foo")) - viewStore.send(.setText("Bar")) + store.send(.keyedChild(action: .setText("BBB"), key: "a")) + store.send(.keyedChild(action: .setText("AAA"), key: "a")) XCTAssertEqual( - viewStore.state.text, - "Bar" + store.state.keyedChildren["a"]?.text, + "AAA", + "KeyedCursor updates model at key" ) - XCTAssertEqual( - store.state.child.text, - "Bar" + } + + func testCursorUpdateTransaction() throws { + let update = ParentChildCursor.update( + state: ParentModel(), + action: ChildAction.setText("Foo"), + environment: () ) - XCTAssertEqual( - store.state.edits, - 2 + XCTAssertNotNil( + update.transaction, + "Transaction is preserved by cursor" ) } - - /// Test creating binding from a ViewStore - func testViewStoreBinding() throws { + + func testCursorUpdate() throws { let store = Store( state: ParentModel(), environment: () ) - - let viewStore: ViewStore = ViewStore( - store: store, - cursor: ParentChildCursor.self - ) - - let binding = Binding( - store: viewStore, - get: \.text, - tag: ChildAction.setText - ) - - let view = SimpleView(text: binding) - - view.text = "Foo" - view.text = "Bar" - - XCTAssertEqual( - viewStore.state.text, - "Bar" - ) + store.send(.setText("Woo")) + store.send(.setText("Woo")) XCTAssertEqual( store.state.child.text, - "Bar" + "Woo", + "Cursor updates child model" ) XCTAssertEqual( store.state.edits, 2 ) } - - func testCursorUpdateTransaction() throws { - let update = ParentChildCursor.update( - state: ParentModel(), - action: ChildAction.setText("Foo"), + + func testKeyedCursorUpdateMissing() throws { + let store = Store( + state: ParentModel( + keyedChildren: [ + "a": ChildModel(text: "A"), + "b": ChildModel(text: "B"), + "c": ChildModel(text: "C"), + ] + ), environment: () ) + store.send(.keyedChild(action: .setText("ZZZ"), key: "z")) + XCTAssertEqual( + store.state.keyedChildren.count, + 3, + "KeyedCursor update does nothing if key is missing" + ) + XCTAssertNil( + store.state.keyedChildren["z"], + "KeyedCursor update does nothing if key is missing" + ) + } + + func testKeyedCursorUpdateTransaction() throws { + let update: Update = KeyedParentChildCursor.update( + state: ParentModel( + keyedChildren: [ + "a": ChildModel(text: "A"), + "b": ChildModel(text: "B"), + "c": ChildModel(text: "C"), + ] + ), + action: .setText("Foo"), + environment: (), + key: "a" + ) XCTAssertNotNil( update.transaction, "Transaction is preserved by cursor" diff --git a/Tests/ObservableStoreTests/ObservableStoreTests.swift b/Tests/ObservableStoreTests/ObservableStoreTests.swift index b3874eb..2b8cda7 100644 --- a/Tests/ObservableStoreTests/ObservableStoreTests.swift +++ b/Tests/ObservableStoreTests/ObservableStoreTests.swift @@ -11,6 +11,7 @@ final class ObservableStoreTests: XCTestCase { case delayIncrement(Double) case setCount(Int) case setEditor(Editor) + case createEmptyFxThatCompletesImmediately } /// Services like API methods go here @@ -51,6 +52,10 @@ final class ObservableStoreTests: XCTestCase { var model = state model.editor = editor return Update(state: model) + case .createEmptyFxThatCompletesImmediately: + let fx: Fx = Empty(completeImmediately: true) + .eraseToAnyPublisher() + return Update(state: state, fx: fx) } } @@ -90,52 +95,52 @@ final class ObservableStoreTests: XCTestCase { store.send(.increment) XCTAssertEqual(store.state.count, 1, "state is advanced") } - - func testBinding() throws { - let store = Store( - state: AppModel(), - environment: AppModel.Environment() - ) - let view = SimpleCountView( - count: Binding( - store: store, - get: \.count, - tag: AppModel.Action.setCount - ) - ) - view.count = 2 - XCTAssertEqual(view.count, 2, "binding is set") - XCTAssertEqual(store.state.count, 2, "binding sends action to store") - } - - func testDeepBinding() throws { + + /// Tests that the immediately-completing empty Fx used as the default for + /// updates get removed from the cancellables array. + /// + /// Failure to remove immediately-completing fx would cause a memory leak. + func testEmptyFxRemovedOnComplete() { let store = Store( state: AppModel(), environment: AppModel.Environment() ) - let binding = Binding( - store: store, - get: \.editor, - tag: AppModel.Action.setEditor - ) - .input - .text - binding.wrappedValue = "floop" - XCTAssertEqual( - store.state.editor.input.text, - "floop", - "specialized binding sets deep property" + store.send(.increment) + store.send(.increment) + store.send(.increment) + let expectation = XCTestExpectation( + description: "cancellable removed when publisher completes" ) + DispatchQueue.main.async { + XCTAssertEqual( + store.cancellables.count, + 0, + "cancellables removed when publisher completes" + ) + expectation.fulfill() + } + wait(for: [expectation], timeout: 0.1) } - - func testEmptyFxRemovedOnComplete() { + + /// Tests that immediately-completing Fx get removed from the cancellables. + /// + /// array. Failure to remove immediately-completing fx would cause a + /// memory leak. + /// + /// When you don't specify fx for an update, we default to + /// an immediately-completing `Empty` publisher, so this test is + /// technically the same as the one above. The difference is that it + /// does not rely on an implementation detail of `Update` but instead + /// tests this behavior directly, in case the implementation were to + /// change somehow. + func testEmptyFxThatCompleteImmiedatelyRemovedOnComplete() { let store = Store( state: AppModel(), environment: AppModel.Environment() ) - store.send(.increment) - store.send(.increment) - store.send(.increment) + store.send(.createEmptyFxThatCompletesImmediately) + store.send(.createEmptyFxThatCompletesImmediately) + store.send(.createEmptyFxThatCompletesImmediately) let expectation = XCTestExpectation( description: "cancellable removed when publisher completes" ) @@ -149,7 +154,7 @@ final class ObservableStoreTests: XCTestCase { } wait(for: [expectation], timeout: 0.1) } - + func testAsyncFxRemovedOnComplete() { let store = Store( state: AppModel(),