diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/SynchronizedStateReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/SynchronizedStateReducer.swift new file mode 100644 index 000000000000..10eb5694beac --- /dev/null +++ b/Sources/ComposableArchitecture/Reducer/Reducers/SynchronizedStateReducer.swift @@ -0,0 +1,223 @@ +import Foundation + +/// Parameters for `withSynchronizedState` for `Reducer`. +/// This allows providing the reducer with what state to watch and how to +/// synchronize it with parent and other siblings. +public struct SynchronizationParameters { + + public init( + parent: SynchronizationType, + children: [SynchronizationType] + ) { + self.parent = parent + self.children = children + } + + var parent: SynchronizationType + var children: [SynchronizationType] + + /// Defines how to synchronize state amongst the reducers. + public enum SynchronizationType { + + /// Only observe this piece of state for changes. + case observeOnly(KeyPath) + + /// Only update this piece of state if anything changes. But do not + /// propagate its changes to parent or siblings. + case updateOnly(WritableKeyPath) + + /// Update this piece of state if anything else on the parent changed + /// or either of the siblings change. Also if this state changes itself + /// communicate that to parent and siblings. + case synchronize(WritableKeyPath) + } +} + +extension ReducerProtocol { + /// Allows observing a piece of state and synchronizing it across + /// a `Reducer`'s children. This can be configured to either only + /// observe or update the state, or all of that if needed. + /// + /// The order of priority for state changes assumes that parent state if observable will + /// supersede the children state. However, since the reducer does execute one action at a time + /// if used correctly this should not be an issue. The order of priority amongst the children + /// is just the first change in the `children` array. + /// + /// For example, if a parent feature holds onto a piece state that is needed by its children + /// _and_ the state can be mutated `synchronizeState` operator can allow ensuring + /// the states stay in-sync without additional actions to keep it up to date. + /// + /// ```swift + /// struct Child: ReducerProtocol { + /// struct State { + /// var sharedState: Foo + /// // .. + /// } + /// + /// enum Action ... + /// } + /// struct Parent: ReducerProtocol { + /// struct State { + /// var sharedState: Foo + /// var child: Child.State + /// // ... + /// } + /// enum Action { + /// case child(Child.Action) + /// // ... + /// } + /// + /// var body: some ReducerProtocol { + /// Reduce { state, action in + /// Scope(state: \.child, action: /Action.child) { + /// Child() + /// } + /// + /// // Core logic for parent feature + /// } + /// .synchronizeState( + /// over: SynchronizationParameters( + /// parent: .observeOnly(\State.sharedState), + /// children: [ + /// .synchronize(\State.child.sharedState) + /// ] + /// ) + /// ) + /// } + /// } + /// ``` + public func synchronizeState( + over synchronizationParameters: SynchronizationParameters, + fileID: StaticString = #fileID, + line: UInt = #line + ) -> _SynchronizedStateReducer { + return _SynchronizedStateReducer( + parent: self, + synchronizationParameters: synchronizationParameters, + fileID: fileID, + line: line + ) + } +} + +public struct _SynchronizedStateReducer: ReducerProtocol +{ + @usableFromInline + let parent: Parent + + @usableFromInline + let fileID: StaticString + + @usableFromInline + let line: UInt + + @usableFromInline + let synchronizationParameters: SynchronizationParameters + + @usableFromInline + init( + parent: Parent, + synchronizationParameters: SynchronizationParameters, + fileID: StaticString, + line: UInt + ) { + self.parent = parent + self.synchronizationParameters = synchronizationParameters + self.fileID = fileID + self.line = line + } + + public func reduce( + into state: inout Parent.State, action: Parent.Action + ) -> EffectTask { + + // Get parent and children states before running the reducer. + let parentStateBeforeTransformation = state[keyPath: synchronizationParameters.parent.keypath] + let childrenStateBeforeTransformation = synchronizationParameters.children.map { + childParam -> Value? in + if let keypath = childParam.observableKeypath { + return state[keyPath: keypath] + } + return nil + } + + let effects = self.parent.reduce(into: &state, action: action) + + // If we can observe the parent and parent state changed, then + // write the new state and return effects. + if let observable = synchronizationParameters.parent.observableKeypath, + state[keyPath: observable] != parentStateBeforeTransformation + { + synchronizationParameters.children + .compactMap { $0.writableKeypath } + .forEach { keypath in + state[keyPath: keypath] = state[keyPath: observable] + } + + return effects + } + + // If we can observe the parent, then check for state changes + // with children and pick the first change. + let childrenStateAfterTransformation = synchronizationParameters.children.map { + childParam -> Value? in + if let keypath = childParam.observableKeypath { + return state[keyPath: keypath] + } + return nil + } + + if let newState = zip(childrenStateBeforeTransformation, childrenStateAfterTransformation) + .first(where: { $0 != $1 })?.1 + { + + // We can update the parent and other siblings that are allowed. + ([synchronizationParameters.parent.writableKeypath] + + synchronizationParameters.children.map(\.writableKeypath)) + .compactMap { $0 } + .forEach { + state[keyPath: $0] = newState + } + } + + return effects + } +} + +extension SynchronizationParameters.SynchronizationType { + /// Get a read only keypath to observe for changes. + var observableKeypath: KeyPath? { + switch self { + case .observeOnly(let keyPath): + return keyPath + case .updateOnly: + return nil + case .synchronize(let writableKeyPath): + return writableKeyPath + } + } + + /// Get a writable keypath to update for changes. + var writableKeypath: WritableKeyPath? { + switch self { + case .observeOnly: + return nil + case .updateOnly(let writableKeyPath): + return writableKeyPath + case .synchronize(let writableKeyPath): + return writableKeyPath + } + } + + /// Get a read only keypath to observe for changes. + var keypath: KeyPath { + switch self { + case .observeOnly(let keyPath): + return keyPath + case .updateOnly(let keyPath): + return keyPath + case .synchronize(let keyPath): + return keyPath + } + } +} diff --git a/Tests/ComposableArchitectureTests/SynchronizedStateReducerTests.swift b/Tests/ComposableArchitectureTests/SynchronizedStateReducerTests.swift new file mode 100644 index 000000000000..919fc2da792c --- /dev/null +++ b/Tests/ComposableArchitectureTests/SynchronizedStateReducerTests.swift @@ -0,0 +1,437 @@ +import ComposableArchitecture +@_spi(Concurrency) import Dependencies +import XCTest + +@MainActor +final class SynchronizedStateReducerTests: BaseTCATestCase { + + func testChildrenStateUpdateChangesParent() { + struct SingleChildReducer: ReducerProtocol { + typealias State = Parent + typealias Action = Void + + var body: some ReducerProtocol { + Reduce { state, _ in + state.child.foo += 1 + return .none + } + .synchronizeState( + over: .init( + parent: .synchronize(\Parent.foo), + children: [ + .synchronize(\Parent.child.foo) + ] + ) + ) + } + } + + let reducer = SingleChildReducer() + var state = Parent( + foo: 0, + child: .init(foo: 0) + ) + _ = reducer.reduce(into: &state, action: ()) + XCTAssertEqual(1, state.foo) + XCTAssertEqual(1, state.child.foo) + } + + func testParentStateUpdateChangesChildrenState() { + struct MultiChildReducer: ReducerProtocol { + typealias State = ParentMultiChild + typealias Action = Void + + var body: some ReducerProtocol { + Reduce { state, _ in + state.foo += 1 + return .none + } + .synchronizeState( + over: .init( + parent: .synchronize(\ParentMultiChild.foo), + children: [ + .synchronize(\ParentMultiChild.child1.foo), + .synchronize(\ParentMultiChild.child2.foo), + .synchronize(\ParentMultiChild.child3.foo), + ] + ) + ) + } + } + + let reducer = MultiChildReducer() + var state = ParentMultiChild( + foo: 0, + child1: .init(foo: 0), + child2: .init(foo: 0), + child3: .init(foo: 0) + ) + + _ = reducer.reduce(into: &state, action: ()) + XCTAssertEqual(1, state.foo) + XCTAssertEqual(1, state.child1.foo) + XCTAssertEqual(1, state.child2.foo) + XCTAssertEqual(1, state.child3.foo) + } + + func testWriteOnlyParentStateChangeDoesNotUpdateChild() { + struct SingleChildReducer: ReducerProtocol { + typealias State = Parent + typealias Action = Void + + var body: some ReducerProtocol { + Reduce { state, _ in + state.foo += 1 + return .none + } + .synchronizeState( + over: .init( + parent: .updateOnly(\Parent.foo), + children: [ + .synchronize(\Parent.child.foo) + ] + ) + ) + } + } + + let reducer = SingleChildReducer() + var state = Parent( + foo: 0, + child: .init(foo: 0) + ) + _ = reducer.reduce(into: &state, action: ()) + XCTAssertEqual(1, state.foo) + XCTAssertEqual(0, state.child.foo) + } + + func testParentStateChangeDoesNotUpdateReadOnlyChild() { + struct SingleChildReducer: ReducerProtocol { + typealias State = Parent + typealias Action = Void + + var body: some ReducerProtocol { + Reduce { state, _ in + state.foo += 1 + return .none + } + .synchronizeState( + over: .init( + parent: .synchronize(\Parent.foo), + children: [ + .observeOnly(\Parent.child.foo) + ] + ) + ) + } + } + + let reducer = SingleChildReducer() + var state = Parent( + foo: 0, + child: .init(foo: 0) + ) + _ = reducer.reduce(into: &state, action: ()) + XCTAssertEqual(1, state.foo) + XCTAssertEqual(0, state.child.foo) + } + + func testReadOnlyParentWithReadWriteChild() { + struct SingleChildReducer: ReducerProtocol { + typealias State = Parent + typealias Action = ParentAction + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .updateChild: + state.child.foo += 1 + case .updateParent: + state.foo += 2 + } + return .none + } + .synchronizeState( + over: .init( + parent: .updateOnly(\Parent.foo), + children: [ + .synchronize(\Parent.child.foo) + ] + ) + ) + } + } + + let reducer = SingleChildReducer() + var state = Parent( + foo: 0, + child: .init(foo: 0) + ) + // Update parent and nothing changes on child. + _ = reducer.reduce(into: &state, action: .updateParent) + XCTAssertEqual(2, state.foo) + XCTAssertEqual(0, state.child.foo) + + // Update child and it changes on parent too. + _ = reducer.reduce(into: &state, action: .updateChild) + XCTAssertEqual(1, state.foo) + XCTAssertEqual(1, state.child.foo) + } + + func testParentWithReadOnlyChild() { + struct SingleChildReducer: ReducerProtocol { + typealias State = Parent + typealias Action = ParentAction + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .updateChild: + state.child.foo += 1 + case .updateParent: + state.foo += 2 + } + return .none + } + .synchronizeState( + over: .init( + parent: .synchronize(\Parent.foo), + children: [ + .observeOnly(\Parent.child.foo) + ] + ) + ) + } + } + + let reducer = SingleChildReducer() + var state = Parent( + foo: 0, + child: .init(foo: 0) + ) + + // Update parent and nothing changes on child. + _ = reducer.reduce(into: &state, action: .updateParent) + XCTAssertEqual(2, state.foo) + XCTAssertEqual(0, state.child.foo) + + // Update child and parent changes too. + _ = reducer.reduce(into: &state, action: .updateChild) + XCTAssertEqual(1, state.foo) + XCTAssertEqual(1, state.child.foo) + } + + func testMultiChildComboWithReadWriteParent() { + struct MultiChildReducer: ReducerProtocol { + typealias State = ParentMultiChild + typealias Action = ParentMultiChildAction + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .updateChild1: + state.child1.foo += 1 + case .updateChild2: + state.child2.foo += 2 + case .updateChild3: + state.child3.foo += 3 + case .updateParent: + state.foo += 20 + } + return .none + } + .synchronizeState( + over: .init( + parent: .synchronize(\ParentMultiChild.foo), + children: [ + .observeOnly(\ParentMultiChild.child1.foo), + .synchronize(\ParentMultiChild.child2.foo), + .updateOnly(\ParentMultiChild.child3.foo), + ] + ) + ) + } + } + + let reducer = MultiChildReducer() + var state = ParentMultiChild( + foo: 0, + child1: .init(foo: 0), + child2: .init(foo: 0), + child3: .init(foo: 0) + ) + + // Update parent and child 2 and child 3 get updated.. + _ = reducer.reduce(into: &state, action: .updateParent) + XCTAssertEqual(20, state.foo) + XCTAssertEqual(0, state.child1.foo) + XCTAssertEqual(20, state.child2.foo) + XCTAssertEqual(20, state.child3.foo) + + // Update child 3 and nothing changes with others, since it is not tracked for changes. + _ = reducer.reduce(into: &state, action: .updateChild3) + XCTAssertEqual(20, state.foo) + XCTAssertEqual(0, state.child1.foo) + XCTAssertEqual(20, state.child2.foo) + XCTAssertEqual(23, state.child3.foo) + + // Update child 1 and other writable ones change. + _ = reducer.reduce(into: &state, action: .updateChild1) + XCTAssertEqual(1, state.foo) + XCTAssertEqual(1, state.child1.foo) + XCTAssertEqual(1, state.child2.foo) + XCTAssertEqual(1, state.child3.foo) + } + + func testMultiChildComboWithReadOnlyParent() { + struct MultiChildReducer: ReducerProtocol { + typealias State = ParentMultiChild + typealias Action = ParentMultiChildAction + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .updateChild1: + state.child1.foo += 1 + case .updateChild2: + state.child2.foo += 2 + case .updateChild3: + state.child3.foo += 3 + case .updateParent: + state.foo += 20 + } + return .none + } + .synchronizeState( + over: .init( + parent: .observeOnly(\ParentMultiChild.foo), + children: [ + .observeOnly(\ParentMultiChild.child1.foo), + .synchronize(\ParentMultiChild.child2.foo), + .updateOnly(\ParentMultiChild.child3.foo), + ] + ) + ) + } + } + + let reducer = MultiChildReducer() + var state = ParentMultiChild( + foo: 0, + child1: .init(foo: 0), + child2: .init(foo: 0), + child3: .init(foo: 0) + ) + + // Update parent and child 2 and child 3 get updated.. + _ = reducer.reduce(into: &state, action: .updateParent) + XCTAssertEqual(20, state.foo) + XCTAssertEqual(0, state.child1.foo) + XCTAssertEqual(20, state.child2.foo) + XCTAssertEqual(20, state.child3.foo) + + // Update child 3 and nothing changes with others, since it is not tracked for changes. + _ = reducer.reduce(into: &state, action: .updateChild3) + XCTAssertEqual(20, state.foo) + XCTAssertEqual(0, state.child1.foo) + XCTAssertEqual(20, state.child2.foo) + XCTAssertEqual(23, state.child3.foo) + + // Update child 1 and other writable ones change. + _ = reducer.reduce(into: &state, action: .updateChild1) + XCTAssertEqual(20, state.foo) // Parent is read only. + XCTAssertEqual(1, state.child1.foo) + XCTAssertEqual(1, state.child2.foo) + XCTAssertEqual(1, state.child3.foo) + } + + func testMultiChildComboWithWriteOnlyParent() { + struct MultiChildReducer: ReducerProtocol { + typealias State = ParentMultiChild + typealias Action = ParentMultiChildAction + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .updateChild1: + state.child1.foo += 1 + case .updateChild2: + state.child2.foo += 2 + case .updateChild3: + state.child3.foo += 3 + case .updateParent: + state.foo += 20 + } + return .none + } + .synchronizeState( + over: .init( + parent: .updateOnly(\ParentMultiChild.foo), + children: [ + .observeOnly(\ParentMultiChild.child1.foo), + .synchronize(\ParentMultiChild.child2.foo), + .updateOnly(\ParentMultiChild.child3.foo), + ] + ) + ) + } + } + + let reducer = MultiChildReducer() + var state = ParentMultiChild( + foo: 0, + child1: .init(foo: 0), + child2: .init(foo: 0), + child3: .init(foo: 0) + ) + + // Update parent and nothing else changes becuase parent is write only. + _ = reducer.reduce(into: &state, action: .updateParent) + XCTAssertEqual(20, state.foo) + XCTAssertEqual(0, state.child1.foo) + XCTAssertEqual(0, state.child2.foo) + XCTAssertEqual(0, state.child3.foo) + + // Update child 3 and nothing changes with others, since it is not tracked for changes. + _ = reducer.reduce(into: &state, action: .updateChild3) + XCTAssertEqual(20, state.foo) + XCTAssertEqual(0, state.child1.foo) + XCTAssertEqual(0, state.child2.foo) + XCTAssertEqual(3, state.child3.foo) + + // Update child 1 and other writable ones change. + _ = reducer.reduce(into: &state, action: .updateChild1) + XCTAssertEqual(1, state.foo) // Parent is write only. + XCTAssertEqual(1, state.child1.foo) + XCTAssertEqual(1, state.child2.foo) + XCTAssertEqual(1, state.child3.foo) + } +} + +struct Parent: Equatable { + var foo: Int + var child: Child +} + +enum ParentAction: Equatable { + case updateParent + case updateChild +} + +enum ParentMultiChildAction: Equatable { + case updateParent + case updateChild1 + case updateChild2 + case updateChild3 +} + +struct ParentMultiChild: Equatable { + var foo: Int + var child1: Child + var child2: Child + var child3: Child +} + +struct Child: Equatable { + var foo: Int +}