From 94ef253070a474a7433ccc606b7fc780202d54c8 Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Wed, 17 Feb 2021 13:26:03 +1100 Subject: [PATCH 01/11] Add OptionalStoreView and PreviewStore --- .../Store/OptionalStoreView.swift | 47 +++++++++++++++++++ .../RecombinePackage/Store/PreviewStore.swift | 44 +++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 Sources/RecombinePackage/Store/OptionalStoreView.swift create mode 100644 Sources/RecombinePackage/Store/PreviewStore.swift diff --git a/Sources/RecombinePackage/Store/OptionalStoreView.swift b/Sources/RecombinePackage/Store/OptionalStoreView.swift new file mode 100644 index 0000000..80d737e --- /dev/null +++ b/Sources/RecombinePackage/Store/OptionalStoreView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +public struct OptionalStoreView< + BaseState, + SubState: Equatable, + RawAction, + BaseRefinedAction, + SubRefinedAction, + Content: View +>: View { + public typealias Lens = LensedStore< + BaseState, + SubState, + RawAction, + BaseRefinedAction, + SubRefinedAction + > + public typealias Underlying = AnyStore< + BaseState, + SubState?, + RawAction, + BaseRefinedAction, + SubRefinedAction + > + let content: (Lens) -> Content + @ObservedObject var store: Underlying + + public init( + store: Store, + content: @escaping (Lens) -> Content + ) + where Store.SubState == Optional, + Store.BaseState == BaseState, + Store.RawAction == RawAction, + Store.BaseRefinedAction == BaseRefinedAction, + Store.SubRefinedAction == SubRefinedAction + { + self.store = store.eraseToAnyStore() + self.content = content + } + + public var body: some View { + if let state = store.state { + content(store.lensing(state: { _ in state })) + } + } +} diff --git a/Sources/RecombinePackage/Store/PreviewStore.swift b/Sources/RecombinePackage/Store/PreviewStore.swift new file mode 100644 index 0000000..d8e0baa --- /dev/null +++ b/Sources/RecombinePackage/Store/PreviewStore.swift @@ -0,0 +1,44 @@ +import Combine +import SwiftUI + +public class PreviewStore: StoreProtocol { + public typealias SubState = State + public typealias SubRefinedAction = RefinedAction + @Published + public private(set) var state: State + public var statePublisher: Published.Publisher { $state } + public let underlying: BaseStore + public let stateLens: (State) -> State = { $0 } + public let actionPromotion: (RefinedAction) -> RefinedAction = { $0 } + + private var cancellables = Set() + + public required init( + state: State + ) { + self.state = state + self.underlying = BaseStore( + state: state, + reducer: PureReducer(), + middleware: .init { _, _ in Empty() }, + publishOn: RunLoop.main + ) + } + + public func lensing( + state lens: @escaping (SubState) -> NewState, + actions transform: @escaping (NewAction) -> SubRefinedAction + ) -> LensedStore< + State, + NewState, + RawAction, + RefinedAction, + NewAction + > { + .init(store: underlying, lensing: lens, actionPromotion: transform) + } + + public func dispatch(refined _: S) where S.Element == RefinedAction {} + + public func dispatch(raw _: S) where S.Element == RawAction {} +} From 68d3876d6e40e2d7f5ac477c2249b99059af529f Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Tue, 2 Mar 2021 13:36:05 +1100 Subject: [PATCH 02/11] Remove OptionalStoreView and add UIScheduler --- Sources/RecombinePackage/Store/AnyStore.swift | 2 +- .../RecombinePackage/Store/BaseStore.swift | 42 +++------ .../RecombinePackage/Store/LensedStore.swift | 2 +- .../Store/OptionalStoreView.swift | 47 ---------- .../RecombinePackage/Store/PreviewStore.swift | 44 +++------- .../Store/StoreProtocol.swift | 37 +++----- Sources/RecombinePackage/UIScheduler.swift | 86 +++++++++++++++++++ Tests/RecombineTests/StoreTests.swift | 8 +- 8 files changed, 127 insertions(+), 141 deletions(-) delete mode 100644 Sources/RecombinePackage/Store/OptionalStoreView.swift create mode 100644 Sources/RecombinePackage/UIScheduler.swift diff --git a/Sources/RecombinePackage/Store/AnyStore.swift b/Sources/RecombinePackage/Store/AnyStore.swift index 3f10c24..c582aa9 100644 --- a/Sources/RecombinePackage/Store/AnyStore.swift +++ b/Sources/RecombinePackage/Store/AnyStore.swift @@ -1,6 +1,6 @@ import Combine -public class AnyStore: StoreProtocol { +public class AnyStore: StoreProtocol { public let underlying: BaseStore public let stateLens: (BaseState) -> SubState public let actionPromotion: (SubRefinedAction) -> BaseRefinedAction diff --git a/Sources/RecombinePackage/Store/BaseStore.swift b/Sources/RecombinePackage/Store/BaseStore.swift index 104bb45..816d2a2 100644 --- a/Sources/RecombinePackage/Store/BaseStore.swift +++ b/Sources/RecombinePackage/Store/BaseStore.swift @@ -1,6 +1,6 @@ import Combine -public class BaseStore: StoreProtocol { +public class BaseStore: StoreProtocol { public typealias SubState = State public typealias SubRefinedAction = RefinedAction public typealias Action = ActionStrata @@ -10,62 +10,42 @@ public class BaseStore: StoreProtocol { public var underlying: BaseStore { self } public let stateLens: (State) -> State = { $0 } public let rawActions = PassthroughSubject() - public let refinedActions = PassthroughSubject() + public let refinedActions = PassthroughSubject<[RefinedAction], Never>() public let actionPromotion: (RefinedAction) -> RefinedAction = { $0 } public var actions: AnyPublisher { Publishers.Merge( rawActions.map(Action.raw), - refinedActions.map(Action.refined) + refinedActions.flatMap(\.publisher).map(Action.refined) ) .eraseToAnyPublisher() } - private let stateEquality: (State, State) -> Bool private var cancellables = Set() - public required init( + public init( state: State, - stateEquality: @escaping (State, State) -> Bool, reducer: R, - middleware: Middleware, + middleware: Middleware = .init { _, _ in Empty() }, publishOn scheduler: S ) where R.State == State, R.Action == RefinedAction { self.state = state - self.stateEquality = stateEquality - + rawActions.flatMap { [unowned self] action in middleware.transform($state.first(), action) } + .map { [$0] } .subscribe(refinedActions) .store(in: &cancellables) - refinedActions.scan(state) { state, action in - reducer.reduce( - state: state, - action: action - ) + refinedActions.scan(state) { state, actions in + actions.reduce(state, reducer.reduce) } - .removeDuplicates(by: stateEquality) + .removeDuplicates() .receive(on: scheduler) .sink { [unowned self] state in self.state = state } .store(in: &cancellables) } - - public convenience init( - state: State, - reducer: R, - middleware: Middleware, - publishOn scheduler: S - ) where R.State == State, R.Action == RefinedAction, State: Equatable { - self.init( - state: state, - stateEquality: ==, - reducer: reducer, - middleware: middleware, - publishOn: scheduler - ) - } public func lensing( state lens: @escaping (SubState) -> NewState, @@ -81,7 +61,7 @@ public class BaseStore: StoreProtocol { } open func dispatch(refined actions: S) where S.Element == RefinedAction { - actions.forEach(self.refinedActions.send) + self.refinedActions.send(.init(actions)) } open func dispatch(raw actions: S) where S.Element == RawAction { diff --git a/Sources/RecombinePackage/Store/LensedStore.swift b/Sources/RecombinePackage/Store/LensedStore.swift index 60d9a7c..5c899b3 100644 --- a/Sources/RecombinePackage/Store/LensedStore.swift +++ b/Sources/RecombinePackage/Store/LensedStore.swift @@ -1,6 +1,6 @@ import Combine -public class LensedStore: StoreProtocol { +public class LensedStore: StoreProtocol { public typealias StoreType = BaseStore @Published public private(set) var state: SubState diff --git a/Sources/RecombinePackage/Store/OptionalStoreView.swift b/Sources/RecombinePackage/Store/OptionalStoreView.swift deleted file mode 100644 index 80d737e..0000000 --- a/Sources/RecombinePackage/Store/OptionalStoreView.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI - -public struct OptionalStoreView< - BaseState, - SubState: Equatable, - RawAction, - BaseRefinedAction, - SubRefinedAction, - Content: View ->: View { - public typealias Lens = LensedStore< - BaseState, - SubState, - RawAction, - BaseRefinedAction, - SubRefinedAction - > - public typealias Underlying = AnyStore< - BaseState, - SubState?, - RawAction, - BaseRefinedAction, - SubRefinedAction - > - let content: (Lens) -> Content - @ObservedObject var store: Underlying - - public init( - store: Store, - content: @escaping (Lens) -> Content - ) - where Store.SubState == Optional, - Store.BaseState == BaseState, - Store.RawAction == RawAction, - Store.BaseRefinedAction == BaseRefinedAction, - Store.SubRefinedAction == SubRefinedAction - { - self.store = store.eraseToAnyStore() - self.content = content - } - - public var body: some View { - if let state = store.state { - content(store.lensing(state: { _ in state })) - } - } -} diff --git a/Sources/RecombinePackage/Store/PreviewStore.swift b/Sources/RecombinePackage/Store/PreviewStore.swift index d8e0baa..86cd0a4 100644 --- a/Sources/RecombinePackage/Store/PreviewStore.swift +++ b/Sources/RecombinePackage/Store/PreviewStore.swift @@ -1,44 +1,22 @@ import Combine import SwiftUI -public class PreviewStore: StoreProtocol { - public typealias SubState = State - public typealias SubRefinedAction = RefinedAction - @Published - public private(set) var state: State - public var statePublisher: Published.Publisher { $state } - public let underlying: BaseStore - public let stateLens: (State) -> State = { $0 } - public let actionPromotion: (RefinedAction) -> RefinedAction = { $0 } - - private var cancellables = Set() - - public required init( +public class PreviewBaseStore< + State: Equatable, + RawAction, + RefinedAction +>: BaseStore< + State, + RawAction, + RefinedAction +> { + public convenience init( state: State ) { - self.state = state - self.underlying = BaseStore( + self.init( state: state, reducer: PureReducer(), - middleware: .init { _, _ in Empty() }, publishOn: RunLoop.main ) } - - public func lensing( - state lens: @escaping (SubState) -> NewState, - actions transform: @escaping (NewAction) -> SubRefinedAction - ) -> LensedStore< - State, - NewState, - RawAction, - RefinedAction, - NewAction - > { - .init(store: underlying, lensing: lens, actionPromotion: transform) - } - - public func dispatch(refined _: S) where S.Element == RefinedAction {} - - public func dispatch(raw _: S) where S.Element == RawAction {} } diff --git a/Sources/RecombinePackage/Store/StoreProtocol.swift b/Sources/RecombinePackage/Store/StoreProtocol.swift index 522424e..b56f379 100644 --- a/Sources/RecombinePackage/Store/StoreProtocol.swift +++ b/Sources/RecombinePackage/Store/StoreProtocol.swift @@ -2,8 +2,8 @@ import Combine import SwiftUI public protocol StoreProtocol: ObservableObject, Subscriber { - associatedtype BaseState - associatedtype SubState + associatedtype BaseState: Equatable + associatedtype SubState: Equatable associatedtype RawAction associatedtype BaseRefinedAction associatedtype SubRefinedAction @@ -40,18 +40,6 @@ public extension StoreProtocol { lensing(state: lens, actions: { $0 }) } - func lensing( - state keyPath: KeyPath - ) -> LensedStore< - BaseState, - NewState, - RawAction, - BaseRefinedAction, - SubRefinedAction - > { - lensing(state: { $0[keyPath: keyPath] }) - } - func lensing( actions transform: @escaping (NewAction) -> SubRefinedAction ) -> LensedStore< @@ -63,18 +51,21 @@ public extension StoreProtocol { > { lensing(state: { $0 }, actions: transform) } - - func lensing( - state keyPath: KeyPath, - actions transform: @escaping (NewAction) -> SubRefinedAction + + /// Create a LensedStore that cannot be updated with actions. + /// - Parameters: + /// - lens: A lens to the state property. + /// - Returns: A `LensedStore`, whose state is lensed by `keyPath` and whose actions are of type `Never`. + func lensingReadOnly( + state lens: @escaping (SubState) -> NewState ) -> LensedStore< BaseState, NewState, RawAction, BaseRefinedAction, - NewAction + Never > { - lensing(state: { $0[keyPath: keyPath] }, actions: transform) + lensing(state: lens, actions: { _ -> SubRefinedAction in }) } } @@ -86,7 +77,7 @@ public extension StoreProtocol { /// - Returns: A `Binding` whose getter is the property and whose setter dispatches the refined action. func binding( state lens: @escaping (SubState) -> Value, - actions transform: @escaping (Value) -> SubRefinedAction + action transform: @escaping (Value) -> SubRefinedAction ) -> Binding { .init( get: { lens(self.state) }, @@ -96,10 +87,10 @@ public extension StoreProtocol { /// Create a SwiftUI Binding from the `SubState` of the store and a `SubRefinedAction`. /// - Parameters: - /// - actions: The refined action which will be called when the value is changed. + /// - action: The refined action which will be called when the value is changed. /// - Returns: A `Binding` whose getter is the state and whose setter dispatches the refined action. func binding( - actions transform: @escaping (SubState) -> SubRefinedAction + action transform: @escaping (SubState) -> SubRefinedAction ) -> Binding { .init( get: { self.state }, diff --git a/Sources/RecombinePackage/UIScheduler.swift b/Sources/RecombinePackage/UIScheduler.swift new file mode 100644 index 0000000..594cdf4 --- /dev/null +++ b/Sources/RecombinePackage/UIScheduler.swift @@ -0,0 +1,86 @@ +/// MIT License +/// +/// Copyright (c) 2020 Point-Free, Inc. +/// +/// Permission is hereby granted, free of charge, to any person obtaining a copy +/// of this software and associated documentation files (the "Software"), to deal +/// in the Software without restriction, including without limitation the rights +/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +/// copies of the Software, and to permit persons to whom the Software is +/// furnished to do so, subject to the following conditions: +/// +/// The above copyright notice and this permission notice shall be included in all +/// copies or substantial portions of the Software. +/// +/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +/// SOFTWARE. + +import Combine +import Dispatch + +/// A scheduler that executes its work on the main queue as soon as possible. +/// +/// This scheduler is inspired by the +/// [equivalent](https://github.com/ReactiveCocoa/ReactiveSwift/blob/58d92aa01081301549c48a4049e215210f650d07/Sources/Scheduler.swift#L92) +/// scheduler in the [ReactiveSwift](https://github.com/ReactiveCocoa/ReactiveSwift) project. +/// +/// If `UIScheduler.shared.schedule` is invoked from the main thread then the unit of work will be +/// performed immediately. This is in contrast to `DispatchQueue.main.schedule`, which will incur +/// a thread hop before executing since it uses `DispatchQueue.main.async` under the hood. +/// +/// This scheduler can be useful for situations where you need work executed as quickly as +/// possible on the main thread, and for which a thread hop would be problematic, such as when +/// performing animations. +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) +public struct UIScheduler: Scheduler { + public typealias SchedulerOptions = Never + public typealias SchedulerTimeType = DispatchQueue.SchedulerTimeType + + /// The shared instance of the UI scheduler. + /// + /// You cannot create instances of the UI scheduler yourself. Use only the shared instance. + public static let shared = Self() + + public var now: SchedulerTimeType { DispatchQueue.main.now } + public var minimumTolerance: SchedulerTimeType.Stride { DispatchQueue.main.minimumTolerance } + + public func schedule(options: SchedulerOptions? = nil, _ action: @escaping () -> Void) { + if DispatchQueue.getSpecific(key: key) == value { + action() + } else { + DispatchQueue.main.schedule(action) + } + } + + public func schedule( + after date: SchedulerTimeType, + tolerance: SchedulerTimeType.Stride, + options: SchedulerOptions? = nil, + _ action: @escaping () -> Void + ) { + DispatchQueue.main.schedule(after: date, tolerance: tolerance, options: nil, action) + } + + public func schedule( + after date: SchedulerTimeType, + interval: SchedulerTimeType.Stride, + tolerance: SchedulerTimeType.Stride, + options: SchedulerOptions? = nil, + _ action: @escaping () -> Void + ) -> Cancellable { + DispatchQueue.main.schedule( + after: date, interval: interval, tolerance: tolerance, options: nil, action + ) + } + + private init() { _ = setSpecific } +} + +private let key = DispatchSpecificKey() +private let value: UInt8 = 0 +private var setSpecific: () = { DispatchQueue.main.setSpecific(key: key, value: value) }() diff --git a/Tests/RecombineTests/StoreTests.swift b/Tests/RecombineTests/StoreTests.swift index d0da8d3..bcc1c02 100644 --- a/Tests/RecombineTests/StoreTests.swift +++ b/Tests/RecombineTests/StoreTests.swift @@ -56,12 +56,12 @@ class StoreTests: XCTestCase { ) let binding1 = store.binding( state: \.subState.value, - actions: { .sub(.set("\($0)1")) } + action: { .sub(.set("\($0)1")) } ) let binding2 = store.lensing( state: \.subState.value ).binding( - actions: { .sub(.set("\($0)2")) } + action: { .sub(.set("\($0)2")) } ) let binding3 = store.lensing( state: \.subState, @@ -108,16 +108,14 @@ class DeInitStore: BaseStore( + override init( state: State, - stateEquality: @escaping (State, State) -> Bool, reducer: R, middleware: Middleware = .init(), publishOn scheduler: S ) where State == R.State, TestFakes.SetAction == R.Action, S : Scheduler, R : Reducer { super.init( state: state, - stateEquality: stateEquality, reducer: reducer, middleware: middleware, publishOn: scheduler From 7e121e511f77ebf88cfd598ed2da7eebf35069c4 Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Tue, 2 Mar 2021 13:43:02 +1100 Subject: [PATCH 03/11] Add Komondor and SwiftFormat --- Package.resolved | 36 +++++++++++++++++++ Package.swift | 25 +++++++++++-- Sources/RecombinePackage/Middleware.swift | 2 +- Sources/RecombinePackage/Reducer.swift | 12 +++---- Sources/RecombinePackage/Store/AnyStore.swift | 12 +++---- .../RecombinePackage/Store/BaseStore.swift | 5 +-- .../RecombinePackage/Store/LensedStore.swift | 4 +-- .../Store/StoreProtocol.swift | 4 +-- Sources/RecombinePackage/UIScheduler.swift | 6 ++-- Tests/RecombineTests/MiddlewareFakes.swift | 6 ++-- Tests/RecombineTests/ReducerTests.swift | 11 +++--- Tests/RecombineTests/StoreDispatchTests.swift | 7 ++-- .../RecombineTests/StoreMiddlewareTests.swift | 4 +-- Tests/RecombineTests/StoreTests.swift | 8 ++--- Tests/RecombineTests/TestFakes.swift | 6 ++-- 15 files changed, 101 insertions(+), 47 deletions(-) diff --git a/Package.resolved b/Package.resolved index 27560c5..bf83cfe 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,42 @@ "revision": "5c36f3199960776fc055196a93ca04bfc00e1857", "version": "0.7.0" } + }, + { + "package": "Komondor", + "repositoryURL": "https://github.com/shibapm/Komondor.git", + "state": { + "branch": null, + "revision": "855c74f395a4dc9e02828f58d931be6920bcbf6f", + "version": "1.0.6" + } + }, + { + "package": "PackageConfig", + "repositoryURL": "https://github.com/shibapm/PackageConfig.git", + "state": { + "branch": null, + "revision": "bf90dc69fa0792894b08a0b74cf34029694ae486", + "version": "0.13.0" + } + }, + { + "package": "ShellOut", + "repositoryURL": "https://github.com/JohnSundell/ShellOut.git", + "state": { + "branch": null, + "revision": "e1577acf2b6e90086d01a6d5e2b8efdaae033568", + "version": "2.3.0" + } + }, + { + "package": "SwiftFormat", + "repositoryURL": "https://github.com/nicklockwood/SwiftFormat.git", + "state": { + "branch": null, + "revision": "018dc23c09a0aff627c026298fda6551a6b1cb81", + "version": "0.47.12" + } } ] }, diff --git a/Package.swift b/Package.swift index bd2088b..65ac5bc 100644 --- a/Package.swift +++ b/Package.swift @@ -6,17 +6,21 @@ import PackageDescription let package = Package( name: "Recombine", platforms: [ - .macOS(.v10_15), .iOS(.v13), .watchOS(.v6), .tvOS(.v13) + .macOS(.v10_15), .iOS(.v13), .watchOS(.v6), .tvOS(.v13), ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( name: "Recombine", - targets: ["Recombine"]), + targets: ["Recombine"] + ), ], dependencies: [ - // Dependencies declare other packages that this package depends on. + // Lib deps .package(url: "https://github.com/groue/CombineExpectations", from: "0.7.0"), + // Dev deps + .package(url: "https://github.com/nicklockwood/SwiftFormat.git", from: "0.35.8"), + .package(url: "https://github.com/shibapm/Komondor.git", from: "1.0.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -36,3 +40,18 @@ let package = Package( ), ] ) + +#if canImport(PackageConfig) + import PackageConfig + + let config = PackageConfiguration([ + "komondor": [ + "pre-push": "swift test", + "pre-commit": [ + "swift test", + "swift run swiftformat .", + "git add .", + ], + ], + ]).write() +#endif diff --git a/Sources/RecombinePackage/Middleware.swift b/Sources/RecombinePackage/Middleware.swift index 81b1f56..812e4e3 100644 --- a/Sources/RecombinePackage/Middleware.swift +++ b/Sources/RecombinePackage/Middleware.swift @@ -11,7 +11,7 @@ public struct Middleware { /// Create a passthrough Middleware. public init() where Input == Output { - self.transform = { Just($1).eraseToAnyPublisher() } + transform = { Just($1).eraseToAnyPublisher() } } /// Initialises the middleware with a transformative function. diff --git a/Sources/RecombinePackage/Reducer.swift b/Sources/RecombinePackage/Reducer.swift index 9ac20ab..a610eb8 100644 --- a/Sources/RecombinePackage/Reducer.swift +++ b/Sources/RecombinePackage/Reducer.swift @@ -10,13 +10,13 @@ public protocol Reducer { func concat(_ other: R) -> Self where R.Transform == Transform } -extension Reducer { - public init(_ reducers: Self...) { +public extension Reducer { + init(_ reducers: Self...) { self = .init(reducers) } - public init(_ reducers: S) where S.Element: Reducer, S.Element.Transform == Transform { - self = reducers.reduce(Self.init()) { + init(_ reducers: S) where S.Element: Reducer, S.Element.Transform == Transform { + self = reducers.reduce(Self()) { $0.concat($1) } } @@ -27,7 +27,7 @@ public struct PureReducer: Reducer { public let transform: Transform public init() { - self.transform = { state, _ in state } + transform = { state, _ in state } } public init(_ transform: @escaping Transform) { @@ -54,7 +54,7 @@ public struct MutatingReducer: Reducer { public let transform: Transform public init() { - self.transform = { _, _ in } + transform = { _, _ in } } public init(_ transform: @escaping Transform) { diff --git a/Sources/RecombinePackage/Store/AnyStore.swift b/Sources/RecombinePackage/Store/AnyStore.swift index c582aa9..c225db7 100644 --- a/Sources/RecombinePackage/Store/AnyStore.swift +++ b/Sources/RecombinePackage/Store/AnyStore.swift @@ -10,16 +10,16 @@ public class AnyStore.Publisher { $state } public required init(_ store: Store) - where Store.BaseState == BaseState, - Store.SubState == SubState, - Store.RawAction == RawAction, - Store.BaseRefinedAction == BaseRefinedAction, - Store.SubRefinedAction == SubRefinedAction + where Store.BaseState == BaseState, + Store.SubState == SubState, + Store.RawAction == RawAction, + Store.BaseRefinedAction == BaseRefinedAction, + Store.SubRefinedAction == SubRefinedAction { underlying = store.underlying stateLens = store.stateLens actionPromotion = store.actionPromotion - self.state = store.state + state = store.state store.statePublisher.sink { [unowned self] state in self.state = state } diff --git a/Sources/RecombinePackage/Store/BaseStore.swift b/Sources/RecombinePackage/Store/BaseStore.swift index 816d2a2..39f5fee 100644 --- a/Sources/RecombinePackage/Store/BaseStore.swift +++ b/Sources/RecombinePackage/Store/BaseStore.swift @@ -19,6 +19,7 @@ public class BaseStore: StoreProtoco ) .eraseToAnyPublisher() } + private var cancellables = Set() public init( @@ -61,10 +62,10 @@ public class BaseStore: StoreProtoco } open func dispatch(refined actions: S) where S.Element == RefinedAction { - self.refinedActions.send(.init(actions)) + refinedActions.send(.init(actions)) } open func dispatch(raw actions: S) where S.Element == RawAction { - actions.forEach(self.rawActions.send) + actions.forEach(rawActions.send) } } diff --git a/Sources/RecombinePackage/Store/LensedStore.swift b/Sources/RecombinePackage/Store/LensedStore.swift index 5c899b3..167a7c5 100644 --- a/Sources/RecombinePackage/Store/LensedStore.swift +++ b/Sources/RecombinePackage/Store/LensedStore.swift @@ -12,8 +12,8 @@ public class LensedStore() public required init(store: StoreType, lensing lens: @escaping (BaseState) -> SubState, actionPromotion: @escaping (SubRefinedAction) -> BaseRefinedAction) { - self.underlying = store - self.stateLens = lens + underlying = store + stateLens = lens self.actionPromotion = actionPromotion state = lens(store.state) store.$state diff --git a/Sources/RecombinePackage/Store/StoreProtocol.swift b/Sources/RecombinePackage/Store/StoreProtocol.swift index b56f379..c610cbe 100644 --- a/Sources/RecombinePackage/Store/StoreProtocol.swift +++ b/Sources/RecombinePackage/Store/StoreProtocol.swift @@ -51,7 +51,7 @@ public extension StoreProtocol { > { lensing(state: { $0 }, actions: transform) } - + /// Create a LensedStore that cannot be updated with actions. /// - Parameters: /// - lens: A lens to the state property. @@ -150,5 +150,5 @@ public extension StoreProtocol { return .unlimited } - func receive(completion: Subscribers.Completion) {} + func receive(completion _: Subscribers.Completion) {} } diff --git a/Sources/RecombinePackage/UIScheduler.swift b/Sources/RecombinePackage/UIScheduler.swift index 594cdf4..7e59a6a 100644 --- a/Sources/RecombinePackage/UIScheduler.swift +++ b/Sources/RecombinePackage/UIScheduler.swift @@ -49,7 +49,7 @@ public struct UIScheduler: Scheduler { public var now: SchedulerTimeType { DispatchQueue.main.now } public var minimumTolerance: SchedulerTimeType.Stride { DispatchQueue.main.minimumTolerance } - public func schedule(options: SchedulerOptions? = nil, _ action: @escaping () -> Void) { + public func schedule(options _: SchedulerOptions? = nil, _ action: @escaping () -> Void) { if DispatchQueue.getSpecific(key: key) == value { action() } else { @@ -60,7 +60,7 @@ public struct UIScheduler: Scheduler { public func schedule( after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, - options: SchedulerOptions? = nil, + options _: SchedulerOptions? = nil, _ action: @escaping () -> Void ) { DispatchQueue.main.schedule(after: date, tolerance: tolerance, options: nil, action) @@ -70,7 +70,7 @@ public struct UIScheduler: Scheduler { after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, - options: SchedulerOptions? = nil, + options _: SchedulerOptions? = nil, _ action: @escaping () -> Void ) -> Cancellable { DispatchQueue.main.schedule( diff --git a/Tests/RecombineTests/MiddlewareFakes.swift b/Tests/RecombineTests/MiddlewareFakes.swift index cbf4dec..a347e6f 100644 --- a/Tests/RecombineTests/MiddlewareFakes.swift +++ b/Tests/RecombineTests/MiddlewareFakes.swift @@ -1,7 +1,7 @@ -import Recombine import Combine +import Recombine -let firstMiddleware = Middleware { state, action -> Just in +let firstMiddleware = Middleware { _, action -> Just in switch action { case let .string(value): return Just(.string(value + " First Middleware")) @@ -10,7 +10,7 @@ let firstMiddleware = Middleware { state, action -> Just in +let secondMiddleware = Middleware { _, action -> Just in switch action { case let .string(value): return Just(.string(value + " Second Middleware")) diff --git a/Tests/RecombineTests/ReducerTests.swift b/Tests/RecombineTests/ReducerTests.swift index 024117e..35a53b0 100644 --- a/Tests/RecombineTests/ReducerTests.swift +++ b/Tests/RecombineTests/ReducerTests.swift @@ -1,28 +1,26 @@ -import XCTest @testable import Recombine +import XCTest class MockReducerContainer { - var calledWithAction: [Action] = [] var reducer: MutatingReducer! init() { - reducer = .init { state, action in + reducer = .init { _, action in self.calledWithAction.append(action) } } } -let increaseByOneReducer: MutatingReducer = .init { state, action in +let increaseByOneReducer: MutatingReducer = .init { state, _ in state.count += 1 } -let increaseByTwoReducer: MutatingReducer = .init { state, action in +let increaseByTwoReducer: MutatingReducer = .init { state, _ in state.count += 2 } class ReducerTests: XCTestCase { - /** it calls each of the reducers with the given action exactly once */ @@ -44,7 +42,6 @@ class ReducerTests: XCTestCase { it combines the results from each individual reducer correctly */ func testCombinesReducerResults() { - let combinedReducer = MutatingReducer(increaseByOneReducer, increaseByTwoReducer) var state = TestFakes.CounterTest.State() combinedReducer.transform(&state, .noop) diff --git a/Tests/RecombineTests/StoreDispatchTests.swift b/Tests/RecombineTests/StoreDispatchTests.swift index 587d9a8..ab8bd2f 100644 --- a/Tests/RecombineTests/StoreDispatchTests.swift +++ b/Tests/RecombineTests/StoreDispatchTests.swift @@ -1,11 +1,10 @@ -import XCTest -@testable import Recombine import Combine +@testable import Recombine +import XCTest -fileprivate typealias StoreTestType = BaseStore +private typealias StoreTestType = BaseStore class ObservableStoreDispatchTests: XCTestCase { - fileprivate var store: StoreTestType! var reducer: MutatingReducer! diff --git a/Tests/RecombineTests/StoreMiddlewareTests.swift b/Tests/RecombineTests/StoreMiddlewareTests.swift index 0983797..0f165a6 100644 --- a/Tests/RecombineTests/StoreMiddlewareTests.swift +++ b/Tests/RecombineTests/StoreMiddlewareTests.swift @@ -1,7 +1,7 @@ -import XCTest -import Foundation import Combine +import Foundation @testable import Recombine +import XCTest class StoreMiddlewareTests: XCTestCase { /** diff --git a/Tests/RecombineTests/StoreTests.swift b/Tests/RecombineTests/StoreTests.swift index bcc1c02..62c7d94 100644 --- a/Tests/RecombineTests/StoreTests.swift +++ b/Tests/RecombineTests/StoreTests.swift @@ -1,7 +1,7 @@ -import XCTest -@testable import Recombine import Combine import CombineExpectations +@testable import Recombine +import XCTest class StoreTests: XCTestCase { /** @@ -22,7 +22,7 @@ class StoreTests: XCTestCase { XCTAssertEqual(deInitCount, 1) } - + func testLensing() throws { let store = BaseStore( state: TestFakes.NestedTest.State(), @@ -113,7 +113,7 @@ class DeInitStore: BaseStore = .init(), publishOn scheduler: S - ) where State == R.State, TestFakes.SetAction == R.Action, S : Scheduler, R : Reducer { + ) where State == R.State, TestFakes.SetAction == R.Action, S: Scheduler, R: Reducer { super.init( state: state, reducer: reducer, diff --git a/Tests/RecombineTests/TestFakes.swift b/Tests/RecombineTests/TestFakes.swift index a1013e7..913b40d 100644 --- a/Tests/RecombineTests/TestFakes.swift +++ b/Tests/RecombineTests/TestFakes.swift @@ -27,6 +27,7 @@ extension TestFakes { enum SubState: Equatable { case set(String) } + case sub(SubState) } @@ -34,6 +35,7 @@ extension TestFakes { struct SubState: Equatable { var value: String = "" } + var subState: SubState = .init() } @@ -51,7 +53,7 @@ extension TestFakes { struct State: Equatable { var value: String? } - + static let reducer = MutatingReducer { state, action in switch action { case let .string(value): @@ -68,7 +70,7 @@ extension TestFakes { struct State: Equatable { var value: Int? } - + static let reducer = MutatingReducer { state, action in switch action { case let .int(value): From bf5213f72eb2d26412e79fd48bb0b83472cdffef Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Fri, 21 May 2021 14:57:15 +1000 Subject: [PATCH 04/11] Remove PreviewStore, add @StoreBinding, add ActionLens --- Package.resolved | 4 +- .../RecombinePackage/Store/ActionLens.swift | 21 +++++++ .../RecombinePackage/Store/BaseStore.swift | 19 +++++- .../RecombinePackage/Store/LensedStore.swift | 10 +--- .../RecombinePackage/Store/PreviewStore.swift | 22 ------- .../Store/StoreProtocol.swift | 58 ++++++++++++++++--- Sources/RecombinePackage/StoreBinding.swift | 51 ++++++++++++++++ Tests/RecombineTests/StoreTests.swift | 2 +- 8 files changed, 145 insertions(+), 42 deletions(-) create mode 100644 Sources/RecombinePackage/Store/ActionLens.swift delete mode 100644 Sources/RecombinePackage/Store/PreviewStore.swift create mode 100644 Sources/RecombinePackage/StoreBinding.swift diff --git a/Package.resolved b/Package.resolved index bf83cfe..9c232f6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -42,8 +42,8 @@ "repositoryURL": "https://github.com/nicklockwood/SwiftFormat.git", "state": { "branch": null, - "revision": "018dc23c09a0aff627c026298fda6551a6b1cb81", - "version": "0.47.12" + "revision": "e8f0d54227f0ca71cdee509164ecedb7d19189fd", + "version": "0.48.2" } } ] diff --git a/Sources/RecombinePackage/Store/ActionLens.swift b/Sources/RecombinePackage/Store/ActionLens.swift new file mode 100644 index 0000000..3b0997f --- /dev/null +++ b/Sources/RecombinePackage/Store/ActionLens.swift @@ -0,0 +1,21 @@ +public struct ActionLens { + let dispatchFunction: (ActionStrata<[RawAction], [SubRefinedAction]>) -> Void + + public func callAsFunction(actions: S) where S.Element == SubRefinedAction { + dispatchFunction(.refined(.init(actions))) + } + + public func callAsFunction(actions: S) where S.Element == RawAction { + dispatchFunction(.raw(.init(actions))) + } +} + +public extension ActionLens { + func callAsFunction(actions: SubRefinedAction...) { + dispatchFunction(.refined(actions)) + } + + func callAsFunction(actions: RawAction...) { + dispatchFunction(.raw(actions)) + } +} diff --git a/Sources/RecombinePackage/Store/BaseStore.swift b/Sources/RecombinePackage/Store/BaseStore.swift index 39f5fee..749a48f 100644 --- a/Sources/RecombinePackage/Store/BaseStore.swift +++ b/Sources/RecombinePackage/Store/BaseStore.swift @@ -11,6 +11,7 @@ public class BaseStore: StoreProtoco public let stateLens: (State) -> State = { $0 } public let rawActions = PassthroughSubject() public let refinedActions = PassthroughSubject<[RefinedAction], Never>() + public let actionsAndState = PassthroughSubject<([RefinedAction], (previous: State, next: State)), Never>() public let actionPromotion: (RefinedAction) -> RefinedAction = { $0 } public var actions: AnyPublisher { Publishers.Merge( @@ -37,13 +38,27 @@ public class BaseStore: StoreProtoco .subscribe(refinedActions) .store(in: &cancellables) + let duplicatedState = PassthroughSubject() + Publishers.Zip( + refinedActions, + duplicatedState + .prepend(state) + .scan([]) { acc, item in .init((acc + [item]).suffix(2)) } + .filter { $0.count == 2 } + .map { ($0[0], $0[1]) } + ) + .sink(receiveValue: actionsAndState.send) + .store(in: &cancellables) + refinedActions.scan(state) { state, actions in actions.reduce(state, reducer.reduce) } - .removeDuplicates() .receive(on: scheduler) .sink { [unowned self] state in - self.state = state + duplicatedState.send(state) + if self.state != state { + self.state = state + } } .store(in: &cancellables) } diff --git a/Sources/RecombinePackage/Store/LensedStore.swift b/Sources/RecombinePackage/Store/LensedStore.swift index 167a7c5..bc74957 100644 --- a/Sources/RecombinePackage/Store/LensedStore.swift +++ b/Sources/RecombinePackage/Store/LensedStore.swift @@ -7,7 +7,7 @@ public class LensedStore.Publisher { $state } public let underlying: BaseStore public let stateLens: (BaseState) -> SubState - public let actions = PassthroughSubject() + public let actions = PassthroughSubject<[SubRefinedAction], Never>() public let actionPromotion: (SubRefinedAction) -> BaseRefinedAction private var cancellables = Set() @@ -23,11 +23,6 @@ public class LensedStore( @@ -49,7 +44,8 @@ public class LensedStore(refined actions: S) where S.Element == SubRefinedAction { - actions.forEach(self.actions.send) + self.actions.send(.init(actions)) + underlying.dispatch(refined: actions.map(actionPromotion)) } open func dispatch(raw actions: S) where S.Element == RawAction { diff --git a/Sources/RecombinePackage/Store/PreviewStore.swift b/Sources/RecombinePackage/Store/PreviewStore.swift deleted file mode 100644 index 86cd0a4..0000000 --- a/Sources/RecombinePackage/Store/PreviewStore.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Combine -import SwiftUI - -public class PreviewBaseStore< - State: Equatable, - RawAction, - RefinedAction ->: BaseStore< - State, - RawAction, - RefinedAction -> { - public convenience init( - state: State - ) { - self.init( - state: state, - reducer: PureReducer(), - publishOn: RunLoop.main - ) - } -} diff --git a/Sources/RecombinePackage/Store/StoreProtocol.swift b/Sources/RecombinePackage/Store/StoreProtocol.swift index c610cbe..d5f08d5 100644 --- a/Sources/RecombinePackage/Store/StoreProtocol.swift +++ b/Sources/RecombinePackage/Store/StoreProtocol.swift @@ -53,19 +53,31 @@ public extension StoreProtocol { } /// Create a LensedStore that cannot be updated with actions. - /// - Parameters: - /// - lens: A lens to the state property. - /// - Returns: A `LensedStore`, whose state is lensed by `keyPath` and whose actions are of type `Never`. - func lensingReadOnly( - state lens: @escaping (SubState) -> NewState - ) -> LensedStore< + /// - Returns: A `LensedStore`, whose state cannot be updated with actions. + func readOnly() -> LensedStore< BaseState, - NewState, + SubState, RawAction, BaseRefinedAction, Never > { - lensing(state: lens, actions: { _ -> SubRefinedAction in }) + lensing(actions: { _ -> SubRefinedAction in }) + } + + /// A lens of the store that can only send actions. + func writeOnly() -> ActionLens< + RawAction, + BaseRefinedAction, + SubRefinedAction + > { + ActionLens { + switch $0 { + case let .raw(actions): + self.underlying.dispatch(raw: actions) + case let .refined(actions): + self.underlying.dispatch(refined: actions.map(self.actionPromotion)) + } + } } } @@ -121,6 +133,36 @@ public extension StoreProtocol { } } +public extension StoreProtocol { + /// Create a SwiftUI Binding from a lensing function and a `RawAction`. + /// - Parameters: + /// - lens: A lens to the state property. + /// - action: The refined action which will be called when the value is changed. + /// - Returns: A `Binding` whose getter is the property and whose setter dispatches the refined action. + func binding( + state lens: @escaping (SubState) -> Value, + rawAction transform: @escaping (Value) -> RawAction + ) -> Binding { + .init( + get: { lens(self.state) }, + set: { self.dispatch(raw: transform($0)) } + ) + } + + /// Create a SwiftUI Binding from the `SubState` of the store and a `RawAction`. + /// - Parameters: + /// - action: The refined action which will be called when the value is changed. + /// - Returns: A `Binding` whose getter is the state and whose setter dispatches the refined action. + func binding( + rawAction transform: @escaping (SubState) -> RawAction + ) -> Binding { + .init( + get: { self.state }, + set: { self.dispatch(raw: transform($0)) } + ) + } +} + public extension StoreProtocol { func dispatch(refined actions: SubRefinedAction...) { dispatch(refined: actions) diff --git a/Sources/RecombinePackage/StoreBinding.swift b/Sources/RecombinePackage/StoreBinding.swift new file mode 100644 index 0000000..8daef72 --- /dev/null +++ b/Sources/RecombinePackage/StoreBinding.swift @@ -0,0 +1,51 @@ +import SwiftUI + +@propertyWrapper +public struct StoreBinding { + private let store: Store + + private let stateLens: (Store.SubState) -> Value + private let actionTransform: (Value) -> ActionStrata + + public var wrappedValue: Value { stateLens(store.state) } + + private init( + store: Store, + stateLens: @escaping (Store.SubState) -> Value, + action: @escaping (Value) -> ActionStrata + ) { + self.store = store + self.stateLens = stateLens + actionTransform = action + } + + public init( + _ store: Store, + state stateLens: @escaping (Store.SubState) -> Value, + rawAction: @escaping (Value) -> Store.RawAction + ) { + self.init(store: store, stateLens: stateLens, action: { .raw(rawAction($0)) }) + } + + public init( + _ store: Store, + state stateLens: @escaping (Store.SubState) -> Value, + refinedAction: @escaping (Value) -> Store.SubRefinedAction + ) { + self.init(store: store, stateLens: stateLens, action: { .refined(refinedAction($0)) }) + } + + public var projectedValue: Binding { + Binding( + get: { wrappedValue }, + set: { + switch actionTransform($0) { + case let .raw(action): + store.dispatch(raw: action) + case let .refined(action): + store.dispatch(refined: action) + } + } + ) + } +} diff --git a/Tests/RecombineTests/StoreTests.swift b/Tests/RecombineTests/StoreTests.swift index 62c7d94..9cf535b 100644 --- a/Tests/RecombineTests/StoreTests.swift +++ b/Tests/RecombineTests/StoreTests.swift @@ -44,7 +44,7 @@ class StoreTests: XCTestCase { let state = try wait(for: stateRecorder.prefix(1), timeout: 1) XCTAssertEqual(state[0], string) let actions = try wait(for: actionsRecorder.prefix(1), timeout: 1) - XCTAssertEqual(actions, [.set(string)]) + XCTAssertEqual(actions, [[.set(string)]]) } func testBinding() throws { From dd6c0817917df78ab75241979646ca8d7fae425c Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Fri, 21 May 2021 16:00:50 +1000 Subject: [PATCH 05/11] Add a synchronous Middleware and rename the async Middle to Thunk. Non-final design. --- Sources/RecombinePackage/Middleware.swift | 34 +++++++++++++-- .../RecombinePackage/OptionalPublisher.swift | 12 ++++++ .../RecombinePackage/Store/BaseStore.swift | 29 +++++++++---- Tests/RecombineTests/MiddlewareFakes.swift | 31 ++++++++++++-- .../RecombineTests/StoreMiddlewareTests.swift | 42 +++++++++++++++++-- Tests/RecombineTests/StoreTests.swift | 13 +++--- 6 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 Sources/RecombinePackage/OptionalPublisher.swift diff --git a/Sources/RecombinePackage/Middleware.swift b/Sources/RecombinePackage/Middleware.swift index 812e4e3..83ed3bd 100644 --- a/Sources/RecombinePackage/Middleware.swift +++ b/Sources/RecombinePackage/Middleware.swift @@ -3,8 +3,36 @@ import Combine /// Middleware is a dependency injection structure that allows you to transform raw actions into refined ones, /// Refined actions produced by Middleware are then forwarded to the main reducer. /// -public struct Middleware { - public typealias StatePublisher = Publishers.First.Publisher> +public struct Middleware { + public typealias Function = (State, Action) -> [Action] + public typealias Transform = (State, Action) -> Result + internal let transform: Function + + /// Create a passthrough Middleware. + public init() { + transform = { [$1] } + } + + /// Initialises the middleware with a transformative function. + /// - parameter transform: The function that will be able to modify passed actions. + public init( + _ transform: @escaping (State, Action) -> S + ) where S.Element == Action { + self.transform = { .init(transform($0, $1)) } + } + + /// Concatenates the transform function of the passed `Middleware` onto the callee's transform. + public func concat(_ other: Self) -> Self { + .init { state, action in + self.transform(state, action).flatMap { + other.transform(state, $0) + } + } + } +} + +public struct Thunk { + public typealias StatePublisher = Published.Publisher public typealias Function = (StatePublisher, Input) -> AnyPublisher public typealias Transform = (StatePublisher, Output) -> Result internal let transform: Function @@ -23,7 +51,7 @@ public struct Middleware { } /// Concatenates the transform function of the passed `Middleware` onto the callee's transform. - public func concat(_ other: Middleware) -> Middleware { + public func concat(_ other: Thunk) -> Thunk { .init { state, action in self.transform(state, action).flatMap { other.transform(state, $0) diff --git a/Sources/RecombinePackage/OptionalPublisher.swift b/Sources/RecombinePackage/OptionalPublisher.swift new file mode 100644 index 0000000..afaee69 --- /dev/null +++ b/Sources/RecombinePackage/OptionalPublisher.swift @@ -0,0 +1,12 @@ +import Combine + +extension Optional { + func publisher() -> AnyPublisher { + switch self { + case let .some(wrapped): + return Just(wrapped).eraseToAnyPublisher() + case .none: + return Empty().eraseToAnyPublisher() + } + } +} diff --git a/Sources/RecombinePackage/Store/BaseStore.swift b/Sources/RecombinePackage/Store/BaseStore.swift index 749a48f..448f013 100644 --- a/Sources/RecombinePackage/Store/BaseStore.swift +++ b/Sources/RecombinePackage/Store/BaseStore.swift @@ -11,7 +11,8 @@ public class BaseStore: StoreProtoco public let stateLens: (State) -> State = { $0 } public let rawActions = PassthroughSubject() public let refinedActions = PassthroughSubject<[RefinedAction], Never>() - public let actionsAndState = PassthroughSubject<([RefinedAction], (previous: State, next: State)), Never>() + public let allStateUpdates = PassthroughSubject() + public let actionsPairedWithState = PassthroughSubject<([RefinedAction], (previous: State, next: State)), Never>() public let actionPromotion: (RefinedAction) -> RefinedAction = { $0 } public var actions: AnyPublisher { Publishers.Merge( @@ -26,36 +27,46 @@ public class BaseStore: StoreProtoco public init( state: State, reducer: R, - middleware: Middleware = .init { _, _ in Empty() }, + middleware: Middleware = .init { [$1] }, + thunk: Thunk = .init { _, _ in Empty() }, publishOn scheduler: S ) where R.State == State, R.Action == RefinedAction { self.state = state rawActions.flatMap { [unowned self] action in - middleware.transform($state.first(), action) + thunk.transform($state, action) } .map { [$0] } .subscribe(refinedActions) .store(in: &cancellables) - let duplicatedState = PassthroughSubject() Publishers.Zip( refinedActions, - duplicatedState + allStateUpdates .prepend(state) .scan([]) { acc, item in .init((acc + [item]).suffix(2)) } .filter { $0.count == 2 } .map { ($0[0], $0[1]) } ) - .sink(receiveValue: actionsAndState.send) + .sink(receiveValue: actionsPairedWithState.send) .store(in: &cancellables) - refinedActions.scan(state) { state, actions in + Publishers.Zip( + refinedActions, + allStateUpdates + .prepend(state) + ) + .map { actions, previousState in + actions.flatMap { middleware.transform(previousState, $0) } + } + .filter { !$0.isEmpty } + .scan(state) { state, actions in actions.reduce(state, reducer.reduce) } .receive(on: scheduler) - .sink { [unowned self] state in - duplicatedState.send(state) + .sink { [weak self] state in + guard let self = self else { return } + self.allStateUpdates.send(state) if self.state != state { self.state = state } diff --git a/Tests/RecombineTests/MiddlewareFakes.swift b/Tests/RecombineTests/MiddlewareFakes.swift index a347e6f..07c4bfc 100644 --- a/Tests/RecombineTests/MiddlewareFakes.swift +++ b/Tests/RecombineTests/MiddlewareFakes.swift @@ -1,7 +1,32 @@ import Combine import Recombine -let firstMiddleware = Middleware { _, action -> Just in +let firstMiddleware = Middleware { _, action -> [TestFakes.SetAction] in + switch action { + case let .string(value): + return [.string(value + " First Middleware")] + default: + return [action] + } +} + +let secondMiddleware = Middleware { _, action -> [TestFakes.SetAction] in + switch action { + case let .string(value): + return [.string(value + " Second Middleware")] + default: + return [action] + } +} + +let stateAccessingMiddleware = Middleware { state, action -> [TestFakes.SetAction] in + if case let .string(value) = action { + return [.string(state.value! + state.value!)] + } + return [action] +} + +let firstThunk = Thunk { _, action -> Just in switch action { case let .string(value): return Just(.string(value + " First Middleware")) @@ -10,7 +35,7 @@ let firstMiddleware = Middleware { _, action -> Just in +let secondThunk = Thunk { _, action -> Just in switch action { case let .string(value): return Just(.string(value + " Second Middleware")) @@ -19,7 +44,7 @@ let secondMiddleware = Middleware { state, action -> AnyPublisher in +let stateAccessingThunk = Thunk { state, action -> AnyPublisher in if case let .string(value) = action { return state.map { .string($0.value! + $0.value!) diff --git a/Tests/RecombineTests/StoreMiddlewareTests.swift b/Tests/RecombineTests/StoreMiddlewareTests.swift index 0f165a6..57c2d16 100644 --- a/Tests/RecombineTests/StoreMiddlewareTests.swift +++ b/Tests/RecombineTests/StoreMiddlewareTests.swift @@ -7,11 +7,29 @@ class StoreMiddlewareTests: XCTestCase { /** it can decorate dispatch function */ - func testDecorateDispatch() { + func testDecorateMiddlewareDispatch() { let store = BaseStore( state: TestFakes.StringTest.State(), reducer: TestFakes.StringTest.reducer, middleware: firstMiddleware.concat(secondMiddleware), + thunk: .init(), + publishOn: ImmediateScheduler.shared + ) + let action = TestFakes.SetAction.string("OK") + store.dispatch(raw: action) + + XCTAssertEqual(store.state.value, "OK First Middleware Second Middleware") + } + + /** + it can decorate dispatch function + */ + func testDecorateThunkDispatch() { + let store = BaseStore( + state: TestFakes.StringTest.State(), + reducer: TestFakes.StringTest.reducer, + middleware: .init(), + thunk: firstThunk.concat(secondThunk), publishOn: ImmediateScheduler.shared ) let action = TestFakes.SetAction.string("OK") @@ -24,13 +42,31 @@ class StoreMiddlewareTests: XCTestCase { it actions should be multiplied via the increase function */ func testMiddlewareMultiplies() { - let multiplexingMiddleware = Middleware { - [$1, $1, $1].publisher + let multiplexingMiddleware = Middleware { + [$1, $1, $1] } let store = BaseStore( state: TestFakes.CounterTest.State(count: 0), reducer: increaseByOneReducer, middleware: multiplexingMiddleware, + thunk: .init(), + publishOn: ImmediateScheduler.shared + ) + store.dispatch(refined: .noop) + XCTAssertEqual(store.state.count, 3) + } + + /** + it actions should be multiplied via the increase function + */ + func testThunkMultiplies() { + let multiplexingThunk = Thunk { + [$1, $1, $1].publisher + } + let store = BaseStore( + state: TestFakes.CounterTest.State(count: 0), + reducer: increaseByOneReducer, + thunk: multiplexingThunk, publishOn: ImmediateScheduler.shared ) store.dispatch(raw: .noop) diff --git a/Tests/RecombineTests/StoreTests.swift b/Tests/RecombineTests/StoreTests.swift index 9cf535b..6bacb2c 100644 --- a/Tests/RecombineTests/StoreTests.swift +++ b/Tests/RecombineTests/StoreTests.swift @@ -28,6 +28,7 @@ class StoreTests: XCTestCase { state: TestFakes.NestedTest.State(), reducer: TestFakes.NestedTest.reducer, middleware: .init(), + thunk: .init(), publishOn: ImmediateScheduler.shared ) let subStore = store.lensing( @@ -42,7 +43,7 @@ class StoreTests: XCTestCase { subStore.dispatch(refined: .set(string)) let state = try wait(for: stateRecorder.prefix(1), timeout: 1) - XCTAssertEqual(state[0], string) + XCTAssertEqual(state.first, string) let actions = try wait(for: actionsRecorder.prefix(1), timeout: 1) XCTAssertEqual(actions, [[.set(string)]]) } @@ -52,6 +53,7 @@ class StoreTests: XCTestCase { state: TestFakes.NestedTest.State(), reducer: TestFakes.NestedTest.reducer, middleware: .init(), + thunk: .init(), publishOn: ImmediateScheduler.shared ) let binding1 = store.binding( @@ -96,13 +98,13 @@ class DeInitStore: BaseStore, - middleware: Middleware = .init(), + thunk: Thunk = .init(), deInitAction: @escaping () -> Void ) { self.init( state: state, reducer: reducer, - middleware: middleware, + thunk: thunk, publishOn: ImmediateScheduler.shared ) self.deInitAction = deInitAction @@ -111,13 +113,14 @@ class DeInitStore: BaseStore( state: State, reducer: R, - middleware: Middleware = .init(), + middleware _: Middleware = .init(), + thunk: Thunk = .init(), publishOn scheduler: S ) where State == R.State, TestFakes.SetAction == R.Action, S: Scheduler, R: Reducer { super.init( state: state, reducer: reducer, - middleware: middleware, + thunk: thunk, publishOn: scheduler ) } From 16baf43f1620ba4044d1f5314e2b4e576b0a332d Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Tue, 22 Jun 2021 17:35:15 +1000 Subject: [PATCH 06/11] Tighten up lensing definitions --- README.md | 2 +- Sources/RecombinePackage/Store/AnyStore.swift | 22 ++----------- .../RecombinePackage/Store/BaseStore.swift | 19 +++-------- .../RecombinePackage/Store/LensedStore.swift | 18 ----------- .../Store/StoreProtocol.swift | 32 +++++++++++++------ Tests/RecombineTests/Info.plist | 24 -------------- 6 files changed, 30 insertions(+), 87 deletions(-) delete mode 100644 Tests/RecombineTests/Info.plist diff --git a/README.md b/README.md index 2a6d581..32fd5ee 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A non-comprehensive list of benefits: - **Type-safe**: Recombine uses concrete types, not protocols, for its actions. If you're using enums for your actions (and you should), switch cases will alert you to all of the locations that need updating whenever you make changes to your implementation. - **Screenshotting**: Since your entire app state is driven by actions, you can serialise lists of actions into JSON, pipe them into the app via XCUITest environment variables, and deserialise them into lists of actions to be applied after pressing a single clear overlay button on top of your entire view hierarcy (which notifies the application that you've taken a screenshot and it can continue). No fussing about with button labels and writing specific logic that will break with UI redesigns. - **Replay**: When a user experiences a bug, they can send you a bug report with all of the actions taken up to that point in the application included (please make sure to fuzz out user-sensitive data when collecting these actions). By keeping a `[TimeInterval: [RefinedAction]]` object for use in debugging into which you record your actions (the time interval being the amount of seconds elapsed since the app started), you can replay these actions using a custom handler and see the weird timing bugs that somehow users are amazing at creating, but developers are rarely able to reproduce. -- **Lensing**: Since Recombine dictates that the structure of your code should be like a type-pyramid, it can get rather awkward when you're twelve views down in the stack having to access `state.user.config.information.name.displayName` and update it using `.config(.user(.info(.name(.displayName("Evan Czaplicki")))))`. That's where lensing comes in! Using the power of `@EnvironmentObject`, you can inject lensed stores that can only see a tiny amount of the state, and only send a tiny amount of actions, as per their needs. You can inject as many lensed stores as you like, so long as their types don't conflict. This allows for hassle free lensing into your user state, navigation state, and so on, using multiple `LensedStore` types in any view that requires access to multiple deep nested locations. An added benefit to lensing is that your view won't be refreshed by irrelevant changes to the outer state, since lensed states are required to be `Equatable`. +- **Lensing**: Since Recombine dictates that the structure of your code should be like a type-pyramid, it can get rather awkward when you're twelve views down in the stack having to access `state.user.config.information.name.displayName` and update it using `.config(.user(.info(.name(.displayName("Evan Czaplicki")))))`. That's where lensing comes in! Using the power of `@StateObject`, you can inject lensed stores that can only access a subset of the state, and only send a subset of actions, as per their needs. You can inject as many lensed stores as you like, which allows for hassle free lensing into your user state, navigation state, and so on, using multiple `LensedStore` types in any view that requires access to multiple deep nested locations. An added benefit to lensing is that your view won't be refreshed by irrelevant changes to the outer state, since lensed states are required to be `Equatable`. # About Recombine diff --git a/Sources/RecombinePackage/Store/AnyStore.swift b/Sources/RecombinePackage/Store/AnyStore.swift index c225db7..e2be19b 100644 --- a/Sources/RecombinePackage/Store/AnyStore.swift +++ b/Sources/RecombinePackage/Store/AnyStore.swift @@ -20,30 +20,12 @@ public class AnyStore( - state lens: @escaping (SubState) -> NewState, - actions transform: @escaping (NewAction) -> SubRefinedAction - ) -> LensedStore< - BaseState, - NewState, - RawAction, - BaseRefinedAction, - NewAction - > { - let stateLens = self.stateLens - return .init( - store: underlying, - lensing: { lens(stateLens($0)) }, - actionPromotion: { self.actionPromotion(transform($0)) } - ) - } - public func dispatch(refined actions: S) where S.Element == SubRefinedAction { underlying.dispatch(refined: actions.map(actionPromotion)) } diff --git a/Sources/RecombinePackage/Store/BaseStore.swift b/Sources/RecombinePackage/Store/BaseStore.swift index 448f013..77cd77a 100644 --- a/Sources/RecombinePackage/Store/BaseStore.swift +++ b/Sources/RecombinePackage/Store/BaseStore.swift @@ -33,8 +33,10 @@ public class BaseStore: StoreProtoco ) where R.State == State, R.Action == RefinedAction { self.state = state - rawActions.flatMap { [unowned self] action in - thunk.transform($state, action) + rawActions.flatMap { [weak self] action in + self.publisher().flatMap { + thunk.transform($0.$state, action) + } } .map { [$0] } .subscribe(refinedActions) @@ -74,19 +76,6 @@ public class BaseStore: StoreProtoco .store(in: &cancellables) } - public func lensing( - state lens: @escaping (SubState) -> NewState, - actions transform: @escaping (NewAction) -> SubRefinedAction - ) -> LensedStore< - State, - NewState, - RawAction, - RefinedAction, - NewAction - > { - .init(store: self, lensing: lens, actionPromotion: transform) - } - open func dispatch(refined actions: S) where S.Element == RefinedAction { refinedActions.send(.init(actions)) } diff --git a/Sources/RecombinePackage/Store/LensedStore.swift b/Sources/RecombinePackage/Store/LensedStore.swift index bc74957..79c2f2c 100644 --- a/Sources/RecombinePackage/Store/LensedStore.swift +++ b/Sources/RecombinePackage/Store/LensedStore.swift @@ -25,24 +25,6 @@ public class LensedStore( - state lens: @escaping (SubState) -> NewState, - actions transform: @escaping (NewAction) -> SubRefinedAction - ) -> LensedStore< - BaseState, - NewState, - RawAction, - BaseRefinedAction, - NewAction - > { - let stateLens = self.stateLens - return .init( - store: underlying, - lensing: { lens(stateLens($0)) }, - actionPromotion: { self.actionPromotion(transform($0)) } - ) - } - open func dispatch(refined actions: S) where S.Element == SubRefinedAction { self.actions.send(.init(actions)) underlying.dispatch(refined: actions.map(actionPromotion)) diff --git a/Sources/RecombinePackage/Store/StoreProtocol.swift b/Sources/RecombinePackage/Store/StoreProtocol.swift index d5f08d5..3abcf8a 100644 --- a/Sources/RecombinePackage/Store/StoreProtocol.swift +++ b/Sources/RecombinePackage/Store/StoreProtocol.swift @@ -14,6 +14,10 @@ public protocol StoreProtocol: ObservableObject, Subscriber { var actionPromotion: (SubRefinedAction) -> BaseRefinedAction { get } func dispatch(raw: S) where S.Element == RawAction func dispatch(refined: S) where S.Element == SubRefinedAction + func eraseToAnyStore() -> AnyStore +} + +public extension StoreProtocol { func lensing( state lens: @escaping (SubState) -> NewState, actions transform: @escaping (NewAction) -> SubRefinedAction @@ -23,11 +27,16 @@ public protocol StoreProtocol: ObservableObject, Subscriber { RawAction, BaseRefinedAction, NewAction - > - func eraseToAnyStore() -> AnyStore -} + > { + let stateLens = self.stateLens + let actionPromotion = self.actionPromotion + return .init( + store: underlying, + lensing: { lens(stateLens($0)) }, + actionPromotion: { actionPromotion(transform($0)) } + ) + } -public extension StoreProtocol { func lensing( state lens: @escaping (SubState) -> NewState ) -> LensedStore< @@ -52,9 +61,8 @@ public extension StoreProtocol { lensing(state: { $0 }, actions: transform) } - /// Create a LensedStore that cannot be updated with actions. - /// - Returns: A `LensedStore`, whose state cannot be updated with actions. - func readOnly() -> LensedStore< + /// Create a `LensedStore` that cannot be updated with actions. + var readOnly: LensedStore< BaseState, SubState, RawAction, @@ -64,8 +72,8 @@ public extension StoreProtocol { lensing(actions: { _ -> SubRefinedAction in }) } - /// A lens of the store that can only send actions. - func writeOnly() -> ActionLens< + /// Create an `ActionLens`, which can only send actions. + var writeOnly: ActionLens< RawAction, BaseRefinedAction, SubRefinedAction @@ -177,6 +185,12 @@ public extension StoreProtocol { } } +public extension StoreProtocol where SubRefinedAction == () { + func dispatchRefined() { + dispatch(refined: ()) + } +} + public extension StoreProtocol { func receive(subscription: Subscription) { subscription.request(.unlimited) diff --git a/Tests/RecombineTests/Info.plist b/Tests/RecombineTests/Info.plist deleted file mode 100644 index f931463..0000000 --- a/Tests/RecombineTests/Info.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - BNDL - CFBundleShortVersionString - 2.0.0 - CFBundleSignature - ???? - CFBundleVersion - 1 - - From 9f79b75c73a3c30254eda1bb9df54a496f02e242 Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Tue, 22 Jun 2021 17:41:12 +1000 Subject: [PATCH 07/11] Split Thunk into its own file --- Sources/RecombinePackage/Middleware.swift | 29 ---------------------- Sources/RecombinePackage/Thunk.swift | 30 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 29 deletions(-) create mode 100644 Sources/RecombinePackage/Thunk.swift diff --git a/Sources/RecombinePackage/Middleware.swift b/Sources/RecombinePackage/Middleware.swift index 83ed3bd..8c13cee 100644 --- a/Sources/RecombinePackage/Middleware.swift +++ b/Sources/RecombinePackage/Middleware.swift @@ -30,32 +30,3 @@ public struct Middleware { } } } - -public struct Thunk { - public typealias StatePublisher = Published.Publisher - public typealias Function = (StatePublisher, Input) -> AnyPublisher - public typealias Transform = (StatePublisher, Output) -> Result - internal let transform: Function - - /// Create a passthrough Middleware. - public init() where Input == Output { - transform = { Just($1).eraseToAnyPublisher() } - } - - /// Initialises the middleware with a transformative function. - /// - parameter transform: The function that will be able to modify passed actions. - public init( - _ transform: @escaping (StatePublisher, Input) -> P - ) where P.Output == Output, P.Failure == Never { - self.transform = { transform($0, $1).eraseToAnyPublisher() } - } - - /// Concatenates the transform function of the passed `Middleware` onto the callee's transform. - public func concat(_ other: Thunk) -> Thunk { - .init { state, action in - self.transform(state, action).flatMap { - other.transform(state, $0) - } - } - } -} diff --git a/Sources/RecombinePackage/Thunk.swift b/Sources/RecombinePackage/Thunk.swift new file mode 100644 index 0000000..dd22b7f --- /dev/null +++ b/Sources/RecombinePackage/Thunk.swift @@ -0,0 +1,30 @@ +import Combine + +public struct Thunk { + public typealias StatePublisher = Published.Publisher + public typealias Function = (StatePublisher, Input) -> AnyPublisher + public typealias Transform = (StatePublisher, Output) -> Result + internal let transform: Function + + /// Create a passthrough Middleware. + public init() where Input == Output { + transform = { Just($1).eraseToAnyPublisher() } + } + + /// Initialises the middleware with a transformative function. + /// - parameter transform: The function that will be able to modify passed actions. + public init( + _ transform: @escaping (StatePublisher, Input) -> P + ) where P.Output == Output, P.Failure == Never { + self.transform = { transform($0, $1).eraseToAnyPublisher() } + } + + /// Concatenates the transform function of the passed `Middleware` onto the callee's transform. + public func concat(_ other: Thunk) -> Thunk { + .init { state, action in + self.transform(state, action).flatMap { + other.transform(state, $0) + } + } + } +} From faf106514a18479512c97f7bdfb1568280981156 Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Thu, 24 Jun 2021 23:59:40 +1000 Subject: [PATCH 08/11] Change Thunk to be able to return raw or refined actions --- .../RecombinePackage/Store/BaseStore.swift | 10 ++++++++-- Sources/RecombinePackage/Thunk.swift | 20 +++++-------------- Tests/RecombineTests/MiddlewareFakes.swift | 18 ++++++++--------- .../RecombineTests/StoreMiddlewareTests.swift | 6 +++--- 4 files changed, 25 insertions(+), 29 deletions(-) diff --git a/Sources/RecombinePackage/Store/BaseStore.swift b/Sources/RecombinePackage/Store/BaseStore.swift index 77cd77a..00dbd7e 100644 --- a/Sources/RecombinePackage/Store/BaseStore.swift +++ b/Sources/RecombinePackage/Store/BaseStore.swift @@ -38,8 +38,14 @@ public class BaseStore: StoreProtoco thunk.transform($0.$state, action) } } - .map { [$0] } - .subscribe(refinedActions) + .sink { [weak self] value in + switch value { + case let .raw(action): + self?.dispatch(raw: action) + case let .refined(action): + self?.dispatch(refined: action) + } + } .store(in: &cancellables) Publishers.Zip( diff --git a/Sources/RecombinePackage/Thunk.swift b/Sources/RecombinePackage/Thunk.swift index dd22b7f..d628794 100644 --- a/Sources/RecombinePackage/Thunk.swift +++ b/Sources/RecombinePackage/Thunk.swift @@ -2,29 +2,19 @@ import Combine public struct Thunk { public typealias StatePublisher = Published.Publisher - public typealias Function = (StatePublisher, Input) -> AnyPublisher - public typealias Transform = (StatePublisher, Output) -> Result + public typealias Function = (StatePublisher, Input) -> AnyPublisher, Never> internal let transform: Function - /// Create a passthrough Middleware. + /// Create a passthrough `Thunk`. public init() where Input == Output { - transform = { Just($1).eraseToAnyPublisher() } + transform = { Just(.refined($1)).eraseToAnyPublisher() } } - /// Initialises the middleware with a transformative function. + /// Initialises the thunk with a transformative function. /// - parameter transform: The function that will be able to modify passed actions. public init( _ transform: @escaping (StatePublisher, Input) -> P - ) where P.Output == Output, P.Failure == Never { + ) where P.Output == ActionStrata, P.Failure == Never { self.transform = { transform($0, $1).eraseToAnyPublisher() } } - - /// Concatenates the transform function of the passed `Middleware` onto the callee's transform. - public func concat(_ other: Thunk) -> Thunk { - .init { state, action in - self.transform(state, action).flatMap { - other.transform(state, $0) - } - } - } } diff --git a/Tests/RecombineTests/MiddlewareFakes.swift b/Tests/RecombineTests/MiddlewareFakes.swift index 07c4bfc..987b6de 100644 --- a/Tests/RecombineTests/MiddlewareFakes.swift +++ b/Tests/RecombineTests/MiddlewareFakes.swift @@ -26,30 +26,30 @@ let stateAccessingMiddleware = Middleware { _, action -> Just in +let firstThunk = Thunk { _, action -> Just> in switch action { case let .string(value): - return Just(.string(value + " First Middleware")) + return Just(.refined(.string(value + " First Middleware"))) default: - return Just(action) + return Just(.refined(action)) } } -let secondThunk = Thunk { _, action -> Just in +let secondThunk = Thunk { _, action -> Just> in switch action { case let .string(value): - return Just(.string(value + " Second Middleware")) + return Just(.refined(.string(value + " Second Middleware"))) default: - return Just(action) + return Just(.refined(action)) } } -let stateAccessingThunk = Thunk { state, action -> AnyPublisher in +let stateAccessingThunk = Thunk { state, action -> AnyPublisher, Never> in if case let .string(value) = action { return state.map { - .string($0.value! + $0.value!) + .refined(.string($0.value! + $0.value!)) } .eraseToAnyPublisher() } - return Just(action).eraseToAnyPublisher() + return Just(.refined(action)).eraseToAnyPublisher() } diff --git a/Tests/RecombineTests/StoreMiddlewareTests.swift b/Tests/RecombineTests/StoreMiddlewareTests.swift index 57c2d16..01fe723 100644 --- a/Tests/RecombineTests/StoreMiddlewareTests.swift +++ b/Tests/RecombineTests/StoreMiddlewareTests.swift @@ -29,13 +29,13 @@ class StoreMiddlewareTests: XCTestCase { state: TestFakes.StringTest.State(), reducer: TestFakes.StringTest.reducer, middleware: .init(), - thunk: firstThunk.concat(secondThunk), + thunk: firstThunk, publishOn: ImmediateScheduler.shared ) let action = TestFakes.SetAction.string("OK") store.dispatch(raw: action) - XCTAssertEqual(store.state.value, "OK First Middleware Second Middleware") + XCTAssertEqual(store.state.value, "OK First Middleware") } /** @@ -61,7 +61,7 @@ class StoreMiddlewareTests: XCTestCase { */ func testThunkMultiplies() { let multiplexingThunk = Thunk { - [$1, $1, $1].publisher + [$1, $1, $1].publisher.map { .refined($0) } } let store = BaseStore( state: TestFakes.CounterTest.State(count: 0), From dda35a40c6c46c76df4916cb88a8e744029c4799 Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Fri, 25 Jun 2021 16:58:35 +1000 Subject: [PATCH 09/11] Reintroduce first publisher limiation on StatePublisher --- Sources/RecombinePackage/Store/BaseStore.swift | 2 +- Sources/RecombinePackage/Thunk.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/RecombinePackage/Store/BaseStore.swift b/Sources/RecombinePackage/Store/BaseStore.swift index 00dbd7e..d762994 100644 --- a/Sources/RecombinePackage/Store/BaseStore.swift +++ b/Sources/RecombinePackage/Store/BaseStore.swift @@ -35,7 +35,7 @@ public class BaseStore: StoreProtoco rawActions.flatMap { [weak self] action in self.publisher().flatMap { - thunk.transform($0.$state, action) + thunk.transform($0.$state.first(), action) } } .sink { [weak self] value in diff --git a/Sources/RecombinePackage/Thunk.swift b/Sources/RecombinePackage/Thunk.swift index 990913a..7bea7a6 100644 --- a/Sources/RecombinePackage/Thunk.swift +++ b/Sources/RecombinePackage/Thunk.swift @@ -36,7 +36,7 @@ import Combine /// Then, we replace the `URLSession` publisher with the `statePublisher` using `flatMap(_:)`, which itself returns a refined action: `.setModel(MyModel)`. public struct Thunk { - public typealias StatePublisher = Published.Publisher + public typealias StatePublisher = Publishers.First.Publisher> public typealias Function = (StatePublisher, Input) -> AnyPublisher, Never> internal let transform: Function From be7de5ace60c8bbc8f0c9f8213536d287d3c3555 Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Mon, 28 Jun 2021 10:30:08 +1000 Subject: [PATCH 10/11] Update diagram --- Docs/img/recombine-diagram.svg | 1870 +++++++++++++++++++++++++++++++- 1 file changed, 1865 insertions(+), 5 deletions(-) diff --git a/Docs/img/recombine-diagram.svg b/Docs/img/recombine-diagram.svg index 1deae18..fb56080 100644 --- a/Docs/img/recombine-diagram.svg +++ b/Docs/img/recombine-diagram.svg @@ -1,9 +1,1869 @@ - -  + + View + + +   + + Action + +   + Reducer + + +   + Middleware + + +   + + Store + + State + +   + + + + + + + + +   + Refined action + + +   + Raw action + + + + +   + Refined action +   + State + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Thunk + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Middleware + Reducer + + + + + From 12c0ec229572c9de9420644296760de4cdb13fd6 Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Mon, 28 Jun 2021 11:32:39 +1000 Subject: [PATCH 11/11] Add tests for Thunk redispatching raw --- Tests/RecombineTests/MiddlewareFakes.swift | 19 +++++-------------- .../RecombineTests/StoreMiddlewareTests.swift | 7 +++---- Tests/RecombineTests/TestFakes.swift | 7 +++++++ 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/Tests/RecombineTests/MiddlewareFakes.swift b/Tests/RecombineTests/MiddlewareFakes.swift index 987b6de..18833b9 100644 --- a/Tests/RecombineTests/MiddlewareFakes.swift +++ b/Tests/RecombineTests/MiddlewareFakes.swift @@ -26,21 +26,12 @@ let stateAccessingMiddleware = Middleware { _, action -> Just> in +let thunk = Thunk { _, action -> Just> in switch action { - case let .string(value): - return Just(.refined(.string(value + " First Middleware"))) - default: - return Just(.refined(action)) - } -} - -let secondThunk = Thunk { _, action -> Just> in - switch action { - case let .string(value): - return Just(.refined(.string(value + " Second Middleware"))) - default: - return Just(.refined(action)) + case let .first(value): + return Just(.raw(.second(value + " First Thunk"))) + case let .second(value): + return Just(.refined(.string(value + " Second Thunk"))) } } diff --git a/Tests/RecombineTests/StoreMiddlewareTests.swift b/Tests/RecombineTests/StoreMiddlewareTests.swift index 01fe723..db75dbc 100644 --- a/Tests/RecombineTests/StoreMiddlewareTests.swift +++ b/Tests/RecombineTests/StoreMiddlewareTests.swift @@ -29,13 +29,12 @@ class StoreMiddlewareTests: XCTestCase { state: TestFakes.StringTest.State(), reducer: TestFakes.StringTest.reducer, middleware: .init(), - thunk: firstThunk, + thunk: thunk, publishOn: ImmediateScheduler.shared ) - let action = TestFakes.SetAction.string("OK") - store.dispatch(raw: action) + store.dispatch(raw: .first("OK")) - XCTAssertEqual(store.state.value, "OK First Middleware") + XCTAssertEqual(store.state.value, "OK First Thunk Second Thunk") } /** diff --git a/Tests/RecombineTests/TestFakes.swift b/Tests/RecombineTests/TestFakes.swift index 913b40d..55f2290 100644 --- a/Tests/RecombineTests/TestFakes.swift +++ b/Tests/RecombineTests/TestFakes.swift @@ -48,6 +48,13 @@ extension TestFakes { } } +extension TestFakes { + enum ThunkRawAction { + case first(String) + case second(String) + } +} + extension TestFakes { enum StringTest { struct State: Equatable {