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 + + + + + diff --git a/Package.resolved b/Package.resolved index 27560c5..9c232f6 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": "e8f0d54227f0ca71cdee509164ecedb7d19189fd", + "version": "0.48.2" + } } ] }, 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/README.md b/README.md index dd02f97..9177b22 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/Middleware.swift b/Sources/RecombinePackage/Middleware.swift index 760e18b..276c2d4 100644 --- a/Sources/RecombinePackage/Middleware.swift +++ b/Sources/RecombinePackage/Middleware.swift @@ -1,128 +1,27 @@ import Combine -/// A dependency injection structure where you transform raw actions, into refined actions which are sent to the store's `Reducer`. -/// -/// The middleware is where you handle side effects, asynchronous calls, and generally code which interacts with the outside world (ie: making a network call, loading app data from disk, getting the user's current location), and also aggregate operations like resetting the state. Much like the rest of Recombine, `Middleware` harnesses Combine and its publishers to represent these interactions. -/// -///`Middleware` is generic over 3 types: -/// * `State`: The data structure which represents the current app state. -/// * `Input`: Most commonly raw actions, this is the value that will be transformed into the `Output`. -/// * `Output`: Most commonly refined actions, this is the result of the `Input`'s transformation, which is then sent to the store's `Reducer` -/// -/// When creating the middleware, you pass in the `State`, `Input`, and `Output` in the angle brackets, and then a closure which takes two arguments –  a publisher of `State`, the `Input`, and which returns an `AnyPublisher` of the `Output`. -/// -/// Critically, you don't have access to the current state itself – only a "stream" where you can send refined actions. -/// -/// Because you need to return an `AnyPublisher`, you usually make your asynchronous calls using Combine publishers, which you can `flatMap(_:)` into the `statePublisher` to return a refined action. It is recommended to make publisher extensions on common types which don't already have one, like `FileManager` or `CLLocationManager`. -/// -/// For example, a middleware which handles making a network call and resetting the app's state: -/// -/// static let middleware = Middleware { statePublisher, action -> AnyPublisher in -/// switch action { -/// case let networkCall(url): -/// URLSession.shared.dataTaskPublisher(for: url) -/// .map(\.data) -/// .decode(type: MyModel.self, decoder: JSONDecoder()) -/// .replaceError(with: MyModel()) -/// .flatMap { myModel in -/// statePublisher.map { _ in -/// return .setModel(myModel) -/// } -/// } -/// .eraseToAnyPublisher() -/// } -/// case resetAppState: -/// return [ -/// .setModel(MyModel.empty), -/// .usernameModification(.delete)) -/// ] -/// .publisher -/// .eraseToAnyPublisher() -/// } -/// } -/// In the code above, the network call is made in the form of `URLSession`'s `dataTaskPublisher(for:)`. We decode the data and change the publisher's error type using `replaceError(with:)` (since the returned `AnyPublisher`'s error type must be `Never` – this can be done with other operators like `catch(:)` and `mapError(_:)`). -/// -/// Then, we replace the `URLSession` publisher with the `statePublisher` using `flatMap(_:)`, which itself returns a refined action: `.setModel(MyModel)`. -/// -/// This middleware also handles an aggregate operation, resetting the app state. It simply returns an array of refined actions, which is turned into a publisher using the `publisher` property on the `Sequence` protocol. -public struct Middleware { - public typealias StatePublisher = Publishers.First.Publisher> - public typealias Transform = (StatePublisher, Output) -> Result - /// The closure which takes in the `StatePublisher` and `Input`, and transforms it into an `AnyPublisher`; the heart of the middleware. - internal let transform: (StatePublisher, Input) -> AnyPublisher +/// Middleware is a structure that allows you to transform refined actions, filter them, or add to them, +/// Refined actions produced by Middleware are then forwarded to the main reducer. +public struct Middleware { + public typealias Function = (State, Action) -> [Action] + public typealias Transform = (State, Action) -> Result + internal let transform: Function - /// Create an empty passthrough `Middleware.` - /// - /// The input type must be equivalent to the output type. - /// - ///For example: - /// - /// static let passthroughMiddleware = Middleware() - public init() where Input == Output { - self.transform = { Just($1).eraseToAnyPublisher() } + /// Create a passthrough Middleware. + public init() { + transform = { [$1] } } - /// Initialises the middleware with a closure which handles transforming the raw actions and returning refined actions. - /// - parameter transform: The closure which takes a publisher of `State`, and the `Middleware`'s `Input`, and returns a publisher who's output is the `Middleware`'s `Output`. - /// - /// The `transform` closure takes two parameters: - /// * A publisher wrapping over the state that was passed into the `Middleware`'s angle brackets. - /// * The middleware's input – most commonly raw actions. - /// - /// The closure then returns a publisher who's output is equivalent to the `Middleware`'s `Output` – most commonly refined actions. - /// - /// For example: - /// - /// static let middleware = Middleware { statePublisher, action -> AnyPublisher in - /// switch action { - /// case let findCurrentLocation(service): - /// CLLocationManager.currentLocationPublisher(service: service) - /// .map { LocationModel(location: $0) } - /// .flatMap { location in - /// statePublisher.map { _ in - /// return .setLocation(to: location) - /// } - /// } - /// .catch { err in - /// return Just(.locationError(err)) - /// } - /// .eraseToAnyPublisher() - /// For a more detailed explanation, go to the `Middleware` documentation. - public init( - _ transform: @escaping (StatePublisher, Input) -> P - ) where P.Output == Output, P.Failure == Never { - self.transform = { transform($0, $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 (State, Action) -> S + ) where S.Element == Action { + self.transform = { .init(transform($0, $1)) } } - /// Adds two middlewares together, concatenating the passed-in middleware's closure to the caller's own closure. - /// - Parameter other: The other middleware, who's `State`, `Input`, and `Output` must be equivalent to the callers'. - /// - Returns: A `Middleware` who's closure is the result of concatenating the caller's closure and the passed in middleware's closure. - /// - /// Use this function when you want to break up your middleware code to make it more compositional. - /// - /// For example: - /// - /// static let middleware = Middleware { statePublisher, action -> AnyPublisher in - /// switch action { - /// case loadAppData: - /// FileManager.default.loadPublisher(from: "appData.json", in: .applicationSupportDirectory) - /// .decode(type: State.self, decoder: JSONDecoder()) - /// // etc... - /// default: - /// break - /// } - /// } - /// .concat( - /// Middleware { statePublisher, action -> AnyPublisher in - /// switch action { - /// case let displayBluetoothPeripherals(services: services): - /// CBCentralManager.peripheralsPublisher(services: services) - /// .map(\.peripheralName) - /// // etc... - /// default: - /// break - /// ) - public func concat(_ other: Middleware) -> Middleware { + /// 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) 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/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/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/AnyStore.swift b/Sources/RecombinePackage/Store/AnyStore.swift index 3f10c24..e2be19b 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 @@ -10,40 +10,22 @@ 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 - store.statePublisher.sink { [unowned self] state in - self.state = state + state = store.state + store.statePublisher.sink { [weak self] state in + self?.state = state } .store(in: &cancellables) } - public func lensing( - 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 104bb45..d762994 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,81 +10,83 @@ 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 allStateUpdates = PassthroughSubject() + public let actionsPairedWithState = PassthroughSubject<([RefinedAction], (previous: State, next: State)), 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 { [$1] }, + thunk: Thunk = .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) + + rawActions.flatMap { [weak self] action in + self.publisher().flatMap { + thunk.transform($0.$state.first(), action) + } } - .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( + refinedActions, + allStateUpdates + .prepend(state) + .scan([]) { acc, item in .init((acc + [item]).suffix(2)) } + .filter { $0.count == 2 } + .map { ($0[0], $0[1]) } + ) + .sink(receiveValue: actionsPairedWithState.send) .store(in: &cancellables) - refinedActions.scan(state) { state, action in - reducer.reduce( - state: state, - action: action - ) + 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) } - .removeDuplicates(by: stateEquality) .receive(on: scheduler) - .sink { [unowned self] state in - self.state = state + .sink { [weak self] state in + guard let self = self else { return } + self.allStateUpdates.send(state) + if self.state != state { + 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, - 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 { - actions.forEach(self.refinedActions.send) + 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 60d9a7c..79c2f2c 100644 --- a/Sources/RecombinePackage/Store/LensedStore.swift +++ b/Sources/RecombinePackage/Store/LensedStore.swift @@ -1,19 +1,19 @@ import Combine -public class LensedStore: StoreProtocol { +public class LensedStore: StoreProtocol { public typealias StoreType = BaseStore @Published public private(set) var state: SubState public var statePublisher: Published.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() 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 @@ -23,33 +23,11 @@ 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 { - 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/StoreProtocol.swift b/Sources/RecombinePackage/Store/StoreProtocol.swift index 522424e..3abcf8a 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 @@ -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< @@ -40,41 +49,43 @@ public extension StoreProtocol { lensing(state: lens, actions: { $0 }) } - func lensing( - state keyPath: KeyPath + func lensing( + actions transform: @escaping (NewAction) -> SubRefinedAction ) -> LensedStore< BaseState, - NewState, + SubState, RawAction, BaseRefinedAction, - SubRefinedAction + NewAction > { - lensing(state: { $0[keyPath: keyPath] }) + lensing(state: { $0 }, actions: transform) } - func lensing( - actions transform: @escaping (NewAction) -> SubRefinedAction - ) -> LensedStore< + /// Create a `LensedStore` that cannot be updated with actions. + var readOnly: LensedStore< BaseState, SubState, RawAction, BaseRefinedAction, - NewAction + Never > { - lensing(state: { $0 }, actions: transform) + lensing(actions: { _ -> SubRefinedAction in }) } - func lensing( - state keyPath: KeyPath, - actions transform: @escaping (NewAction) -> SubRefinedAction - ) -> LensedStore< - BaseState, - NewState, + /// Create an `ActionLens`, which can only send actions. + var writeOnly: ActionLens< RawAction, BaseRefinedAction, - NewAction + SubRefinedAction > { - lensing(state: { $0[keyPath: keyPath] }, actions: transform) + ActionLens { + switch $0 { + case let .raw(actions): + self.underlying.dispatch(raw: actions) + case let .refined(actions): + self.underlying.dispatch(refined: actions.map(self.actionPromotion)) + } + } } } @@ -86,7 +97,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 +107,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 }, @@ -130,6 +141,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) @@ -144,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) @@ -159,5 +206,5 @@ public extension StoreProtocol { return .unlimited } - func receive(completion: Subscribers.Completion) {} + func receive(completion _: Subscribers.Completion) {} } 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/Sources/RecombinePackage/Thunk.swift b/Sources/RecombinePackage/Thunk.swift new file mode 100644 index 0000000..7bea7a6 --- /dev/null +++ b/Sources/RecombinePackage/Thunk.swift @@ -0,0 +1,86 @@ +import Combine + +/// The thunk is where you handle side effects, asynchronous calls, and generally code which interacts with the outside world (ie: making a network call, loading app data from disk, getting the user's current location). Much like the rest of Recombine, `Thunk` harnesses Combine and its publishers to represent these interactions. +/// +/// `Thunk` is generic over 3 types: +/// * `State`: The data structure which represents the current app state. +/// * `Input`: Most commonly raw actions, this is the value that will be transformed into the `Output`. +/// * `Output`: `ActionStrata`, most commonly over raw/refined actions. This is the result of the `Input`'s transformation, which is then sent to the store's `Reducer` +/// +/// When creating the thunk, you pass in the `State`, `Input`, and `Output` in the angle brackets, and then a closure which takes two arguments –  a publisher of `State`, the `Input`, and which returns an `AnyPublisher` of the `Output`. +/// +/// Critically, you don't have access to the current state itself – only a "stream" where you can send refined actions. +/// +/// Because you need to return an `AnyPublisher`, you usually make your asynchronous calls using Combine publishers, which you can `flatMap(_:)` into the `statePublisher` to return a refined action. It is recommended to make publisher extensions on common types which don't already have one, like `FileManager` or `CLLocationManager`. +/// +/// For example, a thunk which handles making a network call and resetting the app's state: +/// +/// static let thunk = Thunk { statePublisher, action -> AnyPublisher in +/// switch action { +/// case let networkCall(url): +/// URLSession.shared.dataTaskPublisher(for: url) +/// .map(\.data) +/// .decode(type: MyModel.self, decoder: JSONDecoder()) +/// .replaceError(with: MyModel()) +/// .flatMap { myModel in +/// statePublisher.map { _ in +/// return .refined(.setModel(myModel)) +/// } +/// } +/// .eraseToAnyPublisher() +/// } +/// } +/// } +/// In the code above, the network call is made in the form of `URLSession`'s `dataTaskPublisher(for:)`. We decode the data and change the publisher's error type using `replaceError(with:)` (since the returned `AnyPublisher`'s error type must be `Never` – this can be done with other operators like `catch(:)` and `mapError(_:)`). +/// +/// 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 = Publishers.First.Publisher> + public typealias Function = (StatePublisher, Input) -> AnyPublisher, Never> + internal let transform: Function + + /// Create an empty passthrough `Thunk.` + /// + /// The input type must be equivalent to the output type. + /// + /// For example: + /// + /// static let passthroughThunk = Thunk() + public init() where Input == Output { + transform = { Just(.refined($1)).eraseToAnyPublisher() } + } + + /// Initialises the thunk with a closure which handles transforming the raw actions and returning refined actions. + /// - parameter transform: The closure which takes a publisher of `State`, and the `Thunk`'s `Input`, and returns a publisher who's output is the `Thunk`'s `Output`. + /// + /// The `transform` closure takes two parameters: + /// * A publisher wrapping over the state that was passed into the `Thunk`'s angle brackets. + /// * The middleware's input – most commonly raw actions. + /// + /// The closure then returns a publisher who's output is equivalent to the `Thunk`'s `Output` – an `ActionStrata` that most commonly contains raw/refined actions. + /// + /// For example: + /// + /// static let thunk = Thunk { statePublisher, action -> AnyPublisher, Never> in + /// switch action { + /// case let findCurrentLocation(service): + /// CLLocationManager.currentLocationPublisher(service: service) + /// .map { LocationModel(location: $0) } + /// .flatMap { location in + /// statePublisher.map { _ in + /// return .setLocation(to: location) + /// } + /// } + /// .catch { err in + /// return Just(.locationError(err)) + /// } + /// .map { .refined($0) } + /// .eraseToAnyPublisher() + /// For a more detailed explanation, go to the `Middleware` documentation. + public init( + _ transform: @escaping (StatePublisher, Input) -> P + ) where P.Output == ActionStrata, P.Failure == Never { + self.transform = { transform($0, $1).eraseToAnyPublisher() } + } +} diff --git a/Sources/RecombinePackage/UIScheduler.swift b/Sources/RecombinePackage/UIScheduler.swift new file mode 100644 index 0000000..7e59a6a --- /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/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 - - diff --git a/Tests/RecombineTests/MiddlewareFakes.swift b/Tests/RecombineTests/MiddlewareFakes.swift index cbf4dec..18833b9 100644 --- a/Tests/RecombineTests/MiddlewareFakes.swift +++ b/Tests/RecombineTests/MiddlewareFakes.swift @@ -1,30 +1,46 @@ -import Recombine import Combine +import Recombine -let firstMiddleware = Middleware { state, action -> Just in +let firstMiddleware = Middleware { _, action -> [TestFakes.SetAction] in switch action { case let .string(value): - return Just(.string(value + " First Middleware")) + return [.string(value + " First Middleware")] default: - return Just(action) + return [action] } } -let secondMiddleware = Middleware { state, action -> Just in +let secondMiddleware = Middleware { _, action -> [TestFakes.SetAction] in switch action { case let .string(value): - return Just(.string(value + " Second Middleware")) + return [.string(value + " Second Middleware")] default: - return Just(action) + 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 thunk = Thunk { _, action -> Just> in + switch action { + case let .first(value): + return Just(.raw(.second(value + " First Thunk"))) + case let .second(value): + return Just(.refined(.string(value + " Second Thunk"))) } } -let stateAccessingMiddleware = Middleware { 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/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..db75dbc 100644 --- a/Tests/RecombineTests/StoreMiddlewareTests.swift +++ b/Tests/RecombineTests/StoreMiddlewareTests.swift @@ -1,17 +1,18 @@ -import XCTest -import Foundation import Combine +import Foundation @testable import Recombine +import XCTest 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") @@ -20,17 +21,51 @@ class StoreMiddlewareTests: XCTestCase { 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: thunk, + publishOn: ImmediateScheduler.shared + ) + store.dispatch(raw: .first("OK")) + + XCTAssertEqual(store.state.value, "OK First Thunk Second Thunk") + } + /** 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.map { .refined($0) } + } + 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 d0da8d3..6bacb2c 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,12 +22,13 @@ class StoreTests: XCTestCase { XCTAssertEqual(deInitCount, 1) } - + func testLensing() throws { let store = BaseStore( state: TestFakes.NestedTest.State(), reducer: TestFakes.NestedTest.reducer, middleware: .init(), + thunk: .init(), publishOn: ImmediateScheduler.shared ) let subStore = store.lensing( @@ -42,9 +43,9 @@ 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)]) + XCTAssertEqual(actions, [[.set(string)]]) } func testBinding() throws { @@ -52,16 +53,17 @@ class StoreTests: XCTestCase { state: TestFakes.NestedTest.State(), reducer: TestFakes.NestedTest.reducer, middleware: .init(), + thunk: .init(), publishOn: ImmediateScheduler.shared ) 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, @@ -96,30 +98,29 @@ 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 } - required init( + override init( state: State, - stateEquality: @escaping (State, State) -> Bool, 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 { + ) where State == R.State, TestFakes.SetAction == R.Action, S: Scheduler, R: Reducer { super.init( state: state, - stateEquality: stateEquality, reducer: reducer, - middleware: middleware, + thunk: thunk, publishOn: scheduler ) } diff --git a/Tests/RecombineTests/TestFakes.swift b/Tests/RecombineTests/TestFakes.swift index a1013e7..55f2290 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() } @@ -46,12 +48,19 @@ extension TestFakes { } } +extension TestFakes { + enum ThunkRawAction { + case first(String) + case second(String) + } +} + extension TestFakes { enum StringTest { struct State: Equatable { var value: String? } - + static let reducer = MutatingReducer { state, action in switch action { case let .string(value): @@ -68,7 +77,7 @@ extension TestFakes { struct State: Equatable { var value: Int? } - + static let reducer = MutatingReducer { state, action in switch action { case let .int(value):