Skip to content

Commit

Permalink
Merge pull request #54 from Ryu0118/refactor
Browse files Browse the repository at this point in the history
Refactor ReducerMacro and Update README
  • Loading branch information
Ryu0118 authored Oct 20, 2023
2 parents 41fe335 + a2160f9 commit 89dff40
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 152 deletions.
15 changes: 11 additions & 4 deletions Examples/Github-App/Github-App/View/RepositoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ struct RepositoryReducer {
case onOpenURLButtonTapped
}

struct ReducerState {
let url: String
}

@Dependency(\.openURL) private var openURL

func reduce(
Expand All @@ -16,20 +20,23 @@ struct RepositoryReducer {
switch action {
case .onOpenURLButtonTapped:
return .run { _ in
await openURL(URL(string: state.repository.url)!)
await openURL(URL(string: state.reducerState.url)!)
}
}
}
}

@ViewState
struct RepositoryView: View {
@State var repository: Repository

let store: Store<RepositoryReducer> = Store(reducer: RepositoryReducer())
let store: Store<RepositoryReducer>
let repository: Repository

init(repository: Repository) {
self.repository = repository
self.store = Store(
reducer: RepositoryReducer(),
initialReducerState: RepositoryReducer.ReducerState(url: repository.url)
)
}

var body: some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ final class RepositoryReducerTests: XCTestCase {
let isCalled = LockIsolated(false)

let store = RepositoryView(repository: .stub)
.testStore(viewState: .init(repository: .stub)) {
.testStore(viewState: .init()) {
$0.openURL = .init { _ in isCalled.setValue(true); return true }
}

Expand Down
158 changes: 102 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ let package = Package(

### Basic Usage
The usage is almost the same as in TCA.
The only difference is the location of the State definition and the names of `StateContainer` and `SideEffect`, etc. are slightly different from TCA.
State definitions are done with property wrappers used in SwiftUI, such as `@State`, `@Binding`, and `@FocusState`.
State definitions use property wrappers used in SwiftUI, such as `@State`, `@Binding`, `@FocusState.
```Swift
@Reducer
struct MyReducer {
Expand Down Expand Up @@ -126,19 +125,7 @@ struct MyView: View {
@State var password: String = ""

let store: Store<MyReducer>

init(authClient: AuthClient) {
store = Store(reducer: MyReducer())
}

var body: some View {
VStack {
...
Button("Login") {
send(.login)
}
}
}
...
}
```

Expand Down Expand Up @@ -177,25 +164,11 @@ struct MyReducer {

@ViewState
struct MyView: View {
@State var counter = 0

let store: Store<MyReducer>

...
init() {
store = Store(reducer: MyReducer(), initialReducerState: MyReducer.ReducerState())
}

var body: some View {
VStack {
Text("\(counter)")
Button("+") {
send(.increment)
}
Button("-") {
send(.decrement)
}
}
}
...
}
```

Expand Down Expand Up @@ -231,44 +204,117 @@ struct ParentReducer {
}
}
}
```

@ViewState
struct ChildView: View, ActionSendable {
let store: Store<ChildReducer> = Store(reducer: ChildReducer())
## Macro
There are two macros in this library:
- `@Reducer`
- `@ViewState`

var body: some View {
Button("Child View") {
send(.onButtonTapped)
### `@Reducer`
`@Reducer` is a macro that integrates `ViewAction` and `ReducerAction` to generate `Action`.
```Swift
@Reducer
struct MyReducer {
enum ViewAction {
case loginButtonTapped
}
enum ReducerAction {
case loginResponse(TaskResult<User>)
}
// expand to ↓
enum Action {
case loginButtonTapped
case loginResponse(TaskResult<User>)

init(viewAction: ViewAction) {
switch viewAction {
case .loginButtonTapped:
self = .loginButtonTapped
}
}

init(reducerAction: ReducerAction) {
switch reducerAction {
case .loginResponse(let arg1):
self = .loginResponse(arg1)
}
}
}
...
}
```
Reducer.reduce(into:action:) no longer needs to be prepared for two actions, ViewAction and ReducerAction, but can be integrated into `Action`.

@Reducer
struct ChildReducer {
enum ViewAction {
case onButtonTapped
}
### `@ViewState
@ViewState creates a ViewState structure and conforms it to the ActionSendable protocol.
```Swift
@ViewState
struct MyView: View {
@State var counter = 0

func reduce(into state: StateContainer<ChildView>, action: Action) -> SideEffect<Self> {
switch action {
case .onButtonTapped:
return .none
let store: Store<MyReducer> = Store(reducer: MyReducer())

var body: some View {
VStack {
Text("\(counter)")
Button("+") {
send(.increment)
}
Button("-") {
send(.decrement)
}
}
}
// expand to ↓
struct ViewState: ViewStateProtocol {
var counter = 0
static let keyPathMap: [PartialKeyPath<ViewState>: PartialKeyPath<MyView>] = [\.counter: \.counter]
}
}
```
The ViewState structure serves two main purposes:

### Testing
You can write a test like this.
- To make properties such as store and body of View inaccessible to Reducer.
- To make it testable.

Also, By conforming to the ActionSendable protocol, you can send Actions to the Store.

## Testing
For testing, we use TestStore. This requires an instance of the ViewState struct, which is generated by the @ViewState macro. Additionally, we'll conduct further operations to assert how its behavior evolves when an action is dispatched.
```Swift
let testStore = TestView().testStore(viewState: .init())
await testStore.send(.increment) {
$0.count = 1
@MainActor
func testReducer() async {
let store = MyView().testStore(viewState: .init())
}

let testStore = TestView().testStore(viewState: .init())
await testStore.send(.send)
await testStore.receive(.increment) {
$0.count = 1
```
Each step of the way we need to prove that state changed how we expect. For example, we can simulate the user flow of tapping on the increment and decrement buttons:
```Swift
@MainActor
func testReducer() async {
let store = MyView().testStore(viewState: .init())
await store.send(.increment) {
$0.count = 1
}
await store.send(.decrement) {
$0.count = 0
}
}
```
Furthermore, when effects are executed by steps and data is fed back into the store, it's necessary to assert on those effects.
```Swift
@MainActor
func testReducer() async {
let store = MyView().testStore(viewState: .init())
await store.send(.fetchData)
await store.send(.fetchDataResponse) {
$0.data = ...
}
}
```
If you're using swift-dependencies, you can perform dependency injection as follows:
```Swift
let store = MyView().testStore(viewState: .init()) {
$0.apiClient.fetchData = { _ in ... }
}
```
1 change: 1 addition & 0 deletions Sources/SimplexArchitecture/Store/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public final class Store<Reducer: ReducerProtocol> {
// Buffer to store Actions recurrently invoked through SideEffect in a single Action sent from View
@TestOnly
var sentFromEffectActions: [ActionTransition<Reducer>] = []

var _send: Send<Reducer>?
var initialReducerState: (() -> Reducer.ReducerState)?
let reduce: (StateContainer<Reducer.Target>, Reducer.Action) -> SideEffect<Reducer>
Expand Down
19 changes: 19 additions & 0 deletions Sources/SimplexArchitectureMacrosPlugin/Extensions/HasName.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,26 @@ extension MemberBlockItemListSyntax.Element {
}
}

extension DeclGroupSyntax {
var hasName: (any HasName)? {
if let enumDecl = `as`(EnumDeclSyntax.self) {
enumDecl
} else if let structDecl = `as`(StructDeclSyntax.self) {
structDecl
} else if let classDecl = `as`(ClassDeclSyntax.self) {
classDecl
} else if let actorDecl = `as`(ActorDeclSyntax.self) {
actorDecl
} else if let protocolDecl = `as`(ProtocolDeclSyntax.self) {
protocolDecl
} else {
nil
}
}
}

extension StructDeclSyntax: HasName {}
extension ClassDeclSyntax: HasName {}
extension ActorDeclSyntax: HasName {}
extension EnumDeclSyntax: HasName {}
extension ProtocolDeclSyntax: HasName {}
Loading

0 comments on commit 89dff40

Please sign in to comment.