Skip to content

Commit e00042f

Browse files
authored
Merge pull request #10 from ReSwift/feature/2.0
Feature/2.0
2 parents 3203bbb + 12c0ec2 commit e00042f

22 files changed

+2445
-333
lines changed

Docs/img/recombine-diagram.svg

+1,865-5
Loading

Package.resolved

+36
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,42 @@
99
"revision": "5c36f3199960776fc055196a93ca04bfc00e1857",
1010
"version": "0.7.0"
1111
}
12+
},
13+
{
14+
"package": "Komondor",
15+
"repositoryURL": "https://github.com/shibapm/Komondor.git",
16+
"state": {
17+
"branch": null,
18+
"revision": "855c74f395a4dc9e02828f58d931be6920bcbf6f",
19+
"version": "1.0.6"
20+
}
21+
},
22+
{
23+
"package": "PackageConfig",
24+
"repositoryURL": "https://github.com/shibapm/PackageConfig.git",
25+
"state": {
26+
"branch": null,
27+
"revision": "bf90dc69fa0792894b08a0b74cf34029694ae486",
28+
"version": "0.13.0"
29+
}
30+
},
31+
{
32+
"package": "ShellOut",
33+
"repositoryURL": "https://github.com/JohnSundell/ShellOut.git",
34+
"state": {
35+
"branch": null,
36+
"revision": "e1577acf2b6e90086d01a6d5e2b8efdaae033568",
37+
"version": "2.3.0"
38+
}
39+
},
40+
{
41+
"package": "SwiftFormat",
42+
"repositoryURL": "https://github.com/nicklockwood/SwiftFormat.git",
43+
"state": {
44+
"branch": null,
45+
"revision": "e8f0d54227f0ca71cdee509164ecedb7d19189fd",
46+
"version": "0.48.2"
47+
}
1248
}
1349
]
1450
},

Package.swift

+22-3
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,21 @@ import PackageDescription
66
let package = Package(
77
name: "Recombine",
88
platforms: [
9-
.macOS(.v10_15), .iOS(.v13), .watchOS(.v6), .tvOS(.v13)
9+
.macOS(.v10_15), .iOS(.v13), .watchOS(.v6), .tvOS(.v13),
1010
],
1111
products: [
1212
// Products define the executables and libraries produced by a package, and make them visible to other packages.
1313
.library(
1414
name: "Recombine",
15-
targets: ["Recombine"]),
15+
targets: ["Recombine"]
16+
),
1617
],
1718
dependencies: [
18-
// Dependencies declare other packages that this package depends on.
19+
// Lib deps
1920
.package(url: "https://github.com/groue/CombineExpectations", from: "0.7.0"),
21+
// Dev deps
22+
.package(url: "https://github.com/nicklockwood/SwiftFormat.git", from: "0.35.8"),
23+
.package(url: "https://github.com/shibapm/Komondor.git", from: "1.0.0"),
2024
],
2125
targets: [
2226
// 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(
3640
),
3741
]
3842
)
43+
44+
#if canImport(PackageConfig)
45+
import PackageConfig
46+
47+
let config = PackageConfiguration([
48+
"komondor": [
49+
"pre-push": "swift test",
50+
"pre-commit": [
51+
"swift test",
52+
"swift run swiftformat .",
53+
"git add .",
54+
],
55+
],
56+
]).write()
57+
#endif

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A non-comprehensive list of benefits:
1515
- **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.
1616
- **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.
1717
- **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.
18-
- **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`.
18+
- **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`.
1919

2020
# About Recombine
2121

Sources/RecombinePackage/Middleware.swift

+17-118
Original file line numberDiff line numberDiff line change
@@ -1,128 +1,27 @@
11
import Combine
22

3-
/// A dependency injection structure where you transform raw actions, into refined actions which are sent to the store's `Reducer`.
4-
///
5-
/// 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.
6-
///
7-
///`Middleware` is generic over 3 types:
8-
/// * `State`: The data structure which represents the current app state.
9-
/// * `Input`: Most commonly raw actions, this is the value that will be transformed into the `Output`.
10-
/// * `Output`: Most commonly refined actions, this is the result of the `Input`'s transformation, which is then sent to the store's `Reducer`
11-
///
12-
/// 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`.
13-
///
14-
/// Critically, you don't have access to the current state itself – only a "stream" where you can send refined actions.
15-
///
16-
/// 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`.
17-
///
18-
/// For example, a middleware which handles making a network call and resetting the app's state:
19-
///
20-
/// static let middleware = Middleware<State, Action.Raw, Action.Refined> { statePublisher, action -> AnyPublisher<Action.Refined, Never> in
21-
/// switch action {
22-
/// case let networkCall(url):
23-
/// URLSession.shared.dataTaskPublisher(for: url)
24-
/// .map(\.data)
25-
/// .decode(type: MyModel.self, decoder: JSONDecoder())
26-
/// .replaceError(with: MyModel())
27-
/// .flatMap { myModel in
28-
/// statePublisher.map { _ in
29-
/// return .setModel(myModel)
30-
/// }
31-
/// }
32-
/// .eraseToAnyPublisher()
33-
/// }
34-
/// case resetAppState:
35-
/// return [
36-
/// .setModel(MyModel.empty),
37-
/// .usernameModification(.delete))
38-
/// ]
39-
/// .publisher
40-
/// .eraseToAnyPublisher()
41-
/// }
42-
/// }
43-
/// 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(_:)`).
44-
///
45-
/// Then, we replace the `URLSession` publisher with the `statePublisher` using `flatMap(_:)`, which itself returns a refined action: `.setModel(MyModel)`.
46-
///
47-
/// 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.
48-
public struct Middleware<State, Input, Output> {
49-
public typealias StatePublisher = Publishers.First<Published<State>.Publisher>
50-
public typealias Transform<Result> = (StatePublisher, Output) -> Result
51-
/// The closure which takes in the `StatePublisher` and `Input`, and transforms it into an `AnyPublisher<Output, Never>`; the heart of the middleware.
52-
internal let transform: (StatePublisher, Input) -> AnyPublisher<Output, Never>
3+
/// Middleware is a structure that allows you to transform refined actions, filter them, or add to them,
4+
/// Refined actions produced by Middleware are then forwarded to the main reducer.
5+
public struct Middleware<State, Action> {
6+
public typealias Function = (State, Action) -> [Action]
7+
public typealias Transform<Result> = (State, Action) -> Result
8+
internal let transform: Function
539

54-
/// Create an empty passthrough `Middleware.`
55-
///
56-
/// The input type must be equivalent to the output type.
57-
///
58-
///For example:
59-
///
60-
/// static let passthroughMiddleware = Middleware<State, Action.Refined, Action.Refined>()
61-
public init() where Input == Output {
62-
self.transform = { Just($1).eraseToAnyPublisher() }
10+
/// Create a passthrough Middleware.
11+
public init() {
12+
transform = { [$1] }
6313
}
6414

65-
/// Initialises the middleware with a closure which handles transforming the raw actions and returning refined actions.
66-
/// - 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`.
67-
///
68-
/// The `transform` closure takes two parameters:
69-
/// * A publisher wrapping over the state that was passed into the `Middleware`'s angle brackets.
70-
/// * The middleware's input – most commonly raw actions.
71-
///
72-
/// The closure then returns a publisher who's output is equivalent to the `Middleware`'s `Output` – most commonly refined actions.
73-
///
74-
/// For example:
75-
///
76-
/// static let middleware = Middleware<State, Action.Raw, Action.Refined> { statePublisher, action -> AnyPublisher<Action.Refined, Never> in
77-
/// switch action {
78-
/// case let findCurrentLocation(service):
79-
/// CLLocationManager.currentLocationPublisher(service: service)
80-
/// .map { LocationModel(location: $0) }
81-
/// .flatMap { location in
82-
/// statePublisher.map { _ in
83-
/// return .setLocation(to: location)
84-
/// }
85-
/// }
86-
/// .catch { err in
87-
/// return Just(.locationError(err))
88-
/// }
89-
/// .eraseToAnyPublisher()
90-
/// For a more detailed explanation, go to the `Middleware` documentation.
91-
public init<P: Publisher>(
92-
_ transform: @escaping (StatePublisher, Input) -> P
93-
) where P.Output == Output, P.Failure == Never {
94-
self.transform = { transform($0, $1).eraseToAnyPublisher() }
15+
/// Initialises the middleware with a transformative function.
16+
/// - parameter transform: The function that will be able to modify passed actions.
17+
public init<S: Sequence>(
18+
_ transform: @escaping (State, Action) -> S
19+
) where S.Element == Action {
20+
self.transform = { .init(transform($0, $1)) }
9521
}
9622

97-
/// Adds two middlewares together, concatenating the passed-in middleware's closure to the caller's own closure.
98-
/// - Parameter other: The other middleware, who's `State`, `Input`, and `Output` must be equivalent to the callers'.
99-
/// - Returns: A `Middleware` who's closure is the result of concatenating the caller's closure and the passed in middleware's closure.
100-
///
101-
/// Use this function when you want to break up your middleware code to make it more compositional.
102-
///
103-
/// For example:
104-
///
105-
/// static let middleware = Middleware<State, Action.Raw, Action.Refined> { statePublisher, action -> AnyPublisher<Action.Refined, Never> in
106-
/// switch action {
107-
/// case loadAppData:
108-
/// FileManager.default.loadPublisher(from: "appData.json", in: .applicationSupportDirectory)
109-
/// .decode(type: State.self, decoder: JSONDecoder())
110-
/// // etc...
111-
/// default:
112-
/// break
113-
/// }
114-
/// }
115-
/// .concat(
116-
/// Middleware<State, Action.Raw, Action.Refined> { statePublisher, action -> AnyPublisher<Action.Refined, Never> in
117-
/// switch action {
118-
/// case let displayBluetoothPeripherals(services: services):
119-
/// CBCentralManager.peripheralsPublisher(services: services)
120-
/// .map(\.peripheralName)
121-
/// // etc...
122-
/// default:
123-
/// break
124-
/// )
125-
public func concat<Result>(_ other: Middleware<State, Output, Result>) -> Middleware<State, Input, Result> {
23+
/// Concatenates the transform function of the passed `Middleware` onto the callee's transform.
24+
public func concat(_ other: Self) -> Self {
12625
.init { state, action in
12726
self.transform(state, action).flatMap {
12827
other.transform(state, $0)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import Combine
2+
3+
extension Optional {
4+
func publisher() -> AnyPublisher<Wrapped, Never> {
5+
switch self {
6+
case let .some(wrapped):
7+
return Just(wrapped).eraseToAnyPublisher()
8+
case .none:
9+
return Empty().eraseToAnyPublisher()
10+
}
11+
}
12+
}

Sources/RecombinePackage/Reducer.swift

+6-6
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ public protocol Reducer {
1010
func concat<R: Reducer>(_ other: R) -> Self where R.Transform == Transform
1111
}
1212

13-
extension Reducer {
14-
public init(_ reducers: Self...) {
13+
public extension Reducer {
14+
init(_ reducers: Self...) {
1515
self = .init(reducers)
1616
}
1717

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

2929
public init() {
30-
self.transform = { state, _ in state }
30+
transform = { state, _ in state }
3131
}
3232

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

5656
public init() {
57-
self.transform = { _, _ in }
57+
transform = { _, _ in }
5858
}
5959

6060
public init(_ transform: @escaping Transform) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
public struct ActionLens<RawAction, BaseRefinedAction, SubRefinedAction> {
2+
let dispatchFunction: (ActionStrata<[RawAction], [SubRefinedAction]>) -> Void
3+
4+
public func callAsFunction<S: Sequence>(actions: S) where S.Element == SubRefinedAction {
5+
dispatchFunction(.refined(.init(actions)))
6+
}
7+
8+
public func callAsFunction<S: Sequence>(actions: S) where S.Element == RawAction {
9+
dispatchFunction(.raw(.init(actions)))
10+
}
11+
}
12+
13+
public extension ActionLens {
14+
func callAsFunction(actions: SubRefinedAction...) {
15+
dispatchFunction(.refined(actions))
16+
}
17+
18+
func callAsFunction(actions: RawAction...) {
19+
dispatchFunction(.raw(actions))
20+
}
21+
}

Sources/RecombinePackage/Store/AnyStore.swift

+9-27
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Combine
22

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

1212
public required init<Store: StoreProtocol>(_ store: Store)
13-
where Store.BaseState == BaseState,
14-
Store.SubState == SubState,
15-
Store.RawAction == RawAction,
16-
Store.BaseRefinedAction == BaseRefinedAction,
17-
Store.SubRefinedAction == SubRefinedAction
13+
where Store.BaseState == BaseState,
14+
Store.SubState == SubState,
15+
Store.RawAction == RawAction,
16+
Store.BaseRefinedAction == BaseRefinedAction,
17+
Store.SubRefinedAction == SubRefinedAction
1818
{
1919
underlying = store.underlying
2020
stateLens = store.stateLens
2121
actionPromotion = store.actionPromotion
22-
self.state = store.state
23-
store.statePublisher.sink { [unowned self] state in
24-
self.state = state
22+
state = store.state
23+
store.statePublisher.sink { [weak self] state in
24+
self?.state = state
2525
}
2626
.store(in: &cancellables)
2727
}
2828

29-
public func lensing<NewState, NewAction>(
30-
state lens: @escaping (SubState) -> NewState,
31-
actions transform: @escaping (NewAction) -> SubRefinedAction
32-
) -> LensedStore<
33-
BaseState,
34-
NewState,
35-
RawAction,
36-
BaseRefinedAction,
37-
NewAction
38-
> {
39-
let stateLens = self.stateLens
40-
return .init(
41-
store: underlying,
42-
lensing: { lens(stateLens($0)) },
43-
actionPromotion: { self.actionPromotion(transform($0)) }
44-
)
45-
}
46-
4729
public func dispatch<S: Sequence>(refined actions: S) where S.Element == SubRefinedAction {
4830
underlying.dispatch(refined: actions.map(actionPromotion))
4931
}

0 commit comments

Comments
 (0)