Skip to content

Commit

Permalink
Merge pull request #10 from ReSwift/feature/2.0
Browse files Browse the repository at this point in the history
Feature/2.0
  • Loading branch information
Qata authored Jun 28, 2021
2 parents 3203bbb + 12c0ec2 commit e00042f
Show file tree
Hide file tree
Showing 22 changed files with 2,445 additions and 333 deletions.
1,870 changes: 1,865 additions & 5 deletions Docs/img/recombine-diagram.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
},
Expand Down
25 changes: 22 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
135 changes: 17 additions & 118 deletions Sources/RecombinePackage/Middleware.swift
Original file line number Diff line number Diff line change
@@ -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<State, Action.Raw, Action.Refined> { statePublisher, action -> AnyPublisher<Action.Refined, Never> 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<State, Input, Output> {
public typealias StatePublisher = Publishers.First<Published<State>.Publisher>
public typealias Transform<Result> = (StatePublisher, Output) -> Result
/// The closure which takes in the `StatePublisher` and `Input`, and transforms it into an `AnyPublisher<Output, Never>`; the heart of the middleware.
internal let transform: (StatePublisher, Input) -> AnyPublisher<Output, Never>
/// 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<State, Action> {
public typealias Function = (State, Action) -> [Action]
public typealias Transform<Result> = (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<State, Action.Refined, Action.Refined>()
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<State, Action.Raw, Action.Refined> { statePublisher, action -> AnyPublisher<Action.Refined, 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))
/// }
/// .eraseToAnyPublisher()
/// For a more detailed explanation, go to the `Middleware` documentation.
public init<P: Publisher>(
_ 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<S: Sequence>(
_ 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<State, Action.Raw, Action.Refined> { statePublisher, action -> AnyPublisher<Action.Refined, Never> in
/// switch action {
/// case loadAppData:
/// FileManager.default.loadPublisher(from: "appData.json", in: .applicationSupportDirectory)
/// .decode(type: State.self, decoder: JSONDecoder())
/// // etc...
/// default:
/// break
/// }
/// }
/// .concat(
/// Middleware<State, Action.Raw, Action.Refined> { statePublisher, action -> AnyPublisher<Action.Refined, Never> in
/// switch action {
/// case let displayBluetoothPeripherals(services: services):
/// CBCentralManager.peripheralsPublisher(services: services)
/// .map(\.peripheralName)
/// // etc...
/// default:
/// break
/// )
public func concat<Result>(_ other: Middleware<State, Output, Result>) -> Middleware<State, Input, Result> {
/// 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)
Expand Down
12 changes: 12 additions & 0 deletions Sources/RecombinePackage/OptionalPublisher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Combine

extension Optional {
func publisher() -> AnyPublisher<Wrapped, Never> {
switch self {
case let .some(wrapped):
return Just(wrapped).eraseToAnyPublisher()
case .none:
return Empty().eraseToAnyPublisher()
}
}
}
12 changes: 6 additions & 6 deletions Sources/RecombinePackage/Reducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ public protocol Reducer {
func concat<R: Reducer>(_ other: R) -> Self where R.Transform == Transform
}

extension Reducer {
public init(_ reducers: Self...) {
public extension Reducer {
init(_ reducers: Self...) {
self = .init(reducers)
}

public init<S: Sequence>(_ reducers: S) where S.Element: Reducer, S.Element.Transform == Transform {
self = reducers.reduce(Self.init()) {
init<S: Sequence>(_ reducers: S) where S.Element: Reducer, S.Element.Transform == Transform {
self = reducers.reduce(Self()) {
$0.concat($1)
}
}
Expand All @@ -27,7 +27,7 @@ public struct PureReducer<State, Action>: Reducer {
public let transform: Transform

public init() {
self.transform = { state, _ in state }
transform = { state, _ in state }
}

public init(_ transform: @escaping Transform) {
Expand All @@ -54,7 +54,7 @@ public struct MutatingReducer<State, Action>: Reducer {
public let transform: Transform

public init() {
self.transform = { _, _ in }
transform = { _, _ in }
}

public init(_ transform: @escaping Transform) {
Expand Down
21 changes: 21 additions & 0 deletions Sources/RecombinePackage/Store/ActionLens.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
public struct ActionLens<RawAction, BaseRefinedAction, SubRefinedAction> {
let dispatchFunction: (ActionStrata<[RawAction], [SubRefinedAction]>) -> Void

public func callAsFunction<S: Sequence>(actions: S) where S.Element == SubRefinedAction {
dispatchFunction(.refined(.init(actions)))
}

public func callAsFunction<S: Sequence>(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))
}
}
36 changes: 9 additions & 27 deletions Sources/RecombinePackage/Store/AnyStore.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Combine

public class AnyStore<BaseState, SubState, RawAction, BaseRefinedAction, SubRefinedAction>: StoreProtocol {
public class AnyStore<BaseState: Equatable, SubState: Equatable, RawAction, BaseRefinedAction, SubRefinedAction>: StoreProtocol {
public let underlying: BaseStore<BaseState, RawAction, BaseRefinedAction>
public let stateLens: (BaseState) -> SubState
public let actionPromotion: (SubRefinedAction) -> BaseRefinedAction
Expand All @@ -10,40 +10,22 @@ public class AnyStore<BaseState, SubState, RawAction, BaseRefinedAction, SubRefi
public var statePublisher: Published<SubState>.Publisher { $state }

public required init<Store: StoreProtocol>(_ 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<NewState, NewAction>(
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<S: Sequence>(refined actions: S) where S.Element == SubRefinedAction {
underlying.dispatch(refined: actions.map(actionPromotion))
}
Expand Down
Loading

0 comments on commit e00042f

Please sign in to comment.