Skip to content

Commit 98af2ad

Browse files
IfLetStore: ignore view store binding writes to nil state (#1879)
* `IfLetStore`: ignore view store binding writes to `nil` state * swift-format * wip * add test for filter --------- Co-authored-by: Brandon Williams <[email protected]>
1 parent 1a168e2 commit 98af2ad

File tree

5 files changed

+120
-28
lines changed

5 files changed

+120
-28
lines changed

Sources/ComposableArchitecture/Store.swift

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,10 @@ public final class Store<State, Action> {
307307
self.threadCheck(status: .scope)
308308

309309
#if swift(>=5.7)
310-
return self.reducer.rescope(self, state: toChildState, action: fromChildAction)
310+
return self.reducer.rescope(self, state: toChildState, action: { fromChildAction($1) })
311311
#else
312312
return (self.scope ?? StoreScope(root: self))
313-
.rescope(self, state: toChildState, action: fromChildAction)
313+
.rescope(self, state: toChildState, action: { fromChildAction($1) })
314314
#endif
315315
}
316316

@@ -326,6 +326,19 @@ public final class Store<State, Action> {
326326
self.scope(state: toChildState, action: { $0 })
327327
}
328328

329+
@_spi(Internals) public func filter(
330+
_ isSent: @escaping (State, Action) -> Bool
331+
) -> Store<State, Action> {
332+
self.threadCheck(status: .scope)
333+
334+
#if swift(>=5.7)
335+
return self.reducer.rescope(self, state: { $0 }, action: { isSent($0, $1) ? $1 : nil })
336+
#else
337+
return (self.scope ?? StoreScope(root: self))
338+
.rescope(self, state: { $0 }, action: { isSent($0, $1) ? $1 : nil })
339+
#endif
340+
}
341+
329342
@_spi(Internals) public func send(
330343
_ action: Action,
331344
originatingFrom originatingAction: Action? = nil
@@ -571,7 +584,7 @@ public typealias StoreOf<R: ReducerProtocol> = Store<R.State, R.Action>
571584
fileprivate func rescope<ChildState, ChildAction>(
572585
_ store: Store<State, Action>,
573586
state toChildState: @escaping (State) -> ChildState,
574-
action fromChildAction: @escaping (ChildAction) -> Action
587+
action fromChildAction: @escaping (ChildState, ChildAction) -> Action?
575588
) -> Store<ChildState, ChildAction> {
576589
(self as? any AnyScopedReducer ?? ScopedReducer(rootStore: store))
577590
.rescope(store, state: toChildState, action: fromChildAction)
@@ -584,7 +597,7 @@ public typealias StoreOf<R: ReducerProtocol> = Store<R.State, R.Action>
584597
let rootStore: Store<RootState, RootAction>
585598
let toScopedState: (RootState) -> ScopedState
586599
private let parentStores: [Any]
587-
let fromScopedAction: (ScopedAction) -> RootAction
600+
let fromScopedAction: (ScopedState, ScopedAction) -> RootAction?
588601
private(set) var isSending = false
589602

590603
@inlinable
@@ -593,14 +606,14 @@ public typealias StoreOf<R: ReducerProtocol> = Store<R.State, R.Action>
593606
self.rootStore = rootStore
594607
self.toScopedState = { $0 }
595608
self.parentStores = []
596-
self.fromScopedAction = { $0 }
609+
self.fromScopedAction = { $1 }
597610
}
598611

599612
@inlinable
600613
init(
601614
rootStore: Store<RootState, RootAction>,
602615
state toScopedState: @escaping (RootState) -> ScopedState,
603-
action fromScopedAction: @escaping (ScopedAction) -> RootAction,
616+
action fromScopedAction: @escaping (ScopedState, ScopedAction) -> RootAction?,
604617
parentStores: [Any]
605618
) {
606619
self.rootStore = rootStore
@@ -618,7 +631,7 @@ public typealias StoreOf<R: ReducerProtocol> = Store<R.State, R.Action>
618631
state = self.toScopedState(self.rootStore.state.value)
619632
self.isSending = false
620633
}
621-
if let task = self.rootStore.send(self.fromScopedAction(action)) {
634+
if let action = self.fromScopedAction(state, action), let task = self.rootStore.send(action) {
622635
return .fireAndForget { await task.cancellableValue }
623636
} else {
624637
return .none
@@ -630,7 +643,7 @@ public typealias StoreOf<R: ReducerProtocol> = Store<R.State, R.Action>
630643
func rescope<ScopedState, ScopedAction, RescopedState, RescopedAction>(
631644
_ store: Store<ScopedState, ScopedAction>,
632645
state toRescopedState: @escaping (ScopedState) -> RescopedState,
633-
action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction
646+
action fromRescopedAction: @escaping (RescopedState, RescopedAction) -> ScopedAction?
634647
) -> Store<RescopedState, RescopedAction>
635648
}
636649

@@ -639,13 +652,13 @@ public typealias StoreOf<R: ReducerProtocol> = Store<R.State, R.Action>
639652
func rescope<ScopedState, ScopedAction, RescopedState, RescopedAction>(
640653
_ store: Store<ScopedState, ScopedAction>,
641654
state toRescopedState: @escaping (ScopedState) -> RescopedState,
642-
action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction
655+
action fromRescopedAction: @escaping (RescopedState, RescopedAction) -> ScopedAction?
643656
) -> Store<RescopedState, RescopedAction> {
644-
let fromScopedAction = self.fromScopedAction as! (ScopedAction) -> RootAction
657+
let fromScopedAction = self.fromScopedAction as! (ScopedState, ScopedAction) -> RootAction?
645658
let reducer = ScopedReducer<RootState, RootAction, RescopedState, RescopedAction>(
646659
rootStore: self.rootStore,
647660
state: { _ in toRescopedState(store.state.value) },
648-
action: { fromScopedAction(fromRescopedAction($0)) },
661+
action: { fromRescopedAction($0, $1).flatMap { fromScopedAction(store.state.value, $0) } },
649662
parentStores: self.parentStores + [store]
650663
)
651664
let childStore = Store<RescopedState, RescopedAction>(
@@ -666,7 +679,7 @@ public typealias StoreOf<R: ReducerProtocol> = Store<R.State, R.Action>
666679
func rescope<ScopedState, ScopedAction, RescopedState, RescopedAction>(
667680
_ store: Store<ScopedState, ScopedAction>,
668681
state toRescopedState: @escaping (ScopedState) -> RescopedState,
669-
action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction
682+
action fromRescopedAction: @escaping (RescopedState, RescopedAction) -> ScopedAction?
670683
) -> Store<RescopedState, RescopedAction>
671684
}
672685

@@ -675,12 +688,15 @@ public typealias StoreOf<R: ReducerProtocol> = Store<R.State, R.Action>
675688
let fromScopedAction: Any
676689

677690
init(root: Store<RootState, RootAction>) {
678-
self.init(root: root, fromScopedAction: { $0 })
691+
self.init(
692+
root: root,
693+
fromScopedAction: { (state: RootState, action: RootAction) -> RootAction? in action }
694+
)
679695
}
680696

681-
private init<ScopedAction>(
697+
private init<ScopedState, ScopedAction>(
682698
root: Store<RootState, RootAction>,
683-
fromScopedAction: @escaping (ScopedAction) -> RootAction
699+
fromScopedAction: @escaping (ScopedState, ScopedAction) -> RootAction?
684700
) {
685701
self.root = root
686702
self.fromScopedAction = fromScopedAction
@@ -689,17 +705,21 @@ public typealias StoreOf<R: ReducerProtocol> = Store<R.State, R.Action>
689705
func rescope<ScopedState, ScopedAction, RescopedState, RescopedAction>(
690706
_ scopedStore: Store<ScopedState, ScopedAction>,
691707
state toRescopedState: @escaping (ScopedState) -> RescopedState,
692-
action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction
708+
action fromRescopedAction: @escaping (RescopedState, RescopedAction) -> ScopedAction?
693709
) -> Store<RescopedState, RescopedAction> {
694-
let fromScopedAction = self.fromScopedAction as! (ScopedAction) -> RootAction
710+
let fromScopedAction = self.fromScopedAction as! (ScopedState, ScopedAction) -> RootAction?
695711

696712
var isSending = false
697713
let rescopedStore = Store<RescopedState, RescopedAction>(
698714
initialState: toRescopedState(scopedStore.state.value),
699715
reducer: .init { rescopedState, rescopedAction, _ in
700716
isSending = true
701717
defer { isSending = false }
702-
let task = self.root.send(fromScopedAction(fromRescopedAction(rescopedAction)))
718+
guard
719+
let scopedAction = fromRescopedAction(rescopedState, rescopedAction),
720+
let rootAction = fromScopedAction(scopedStore.state.value, scopedAction)
721+
else { return .none }
722+
let task = self.root.send(rootAction)
703723
rescopedState = toRescopedState(scopedStore.state.value)
704724
if let task = task {
705725
return .fireAndForget { await task.cancellableValue }
@@ -717,7 +737,9 @@ public typealias StoreOf<R: ReducerProtocol> = Store<R.State, R.Action>
717737
}
718738
rescopedStore.scope = StoreScope<RootState, RootAction>(
719739
root: self.root,
720-
fromScopedAction: { fromScopedAction(fromRescopedAction($0)) }
740+
fromScopedAction: {
741+
fromRescopedAction($0, $1).flatMap { fromScopedAction(scopedStore.state.value, $0) }
742+
}
721743
)
722744
return rescopedStore
723745
}

Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,12 @@ public struct IfLetStore<State, Action, Content: View>: View {
5757
if var state = viewStore.state {
5858
return ViewBuilder.buildEither(
5959
first: ifContent(
60-
store.scope {
61-
state = $0 ?? state
62-
return state
63-
}
60+
store
61+
.filter { state, _ in state == nil ? !BindingLocal.isActive : true }
62+
.scope {
63+
state = $0 ?? state
64+
return state
65+
}
6466
)
6567
)
6668
} else {
@@ -84,10 +86,12 @@ public struct IfLetStore<State, Action, Content: View>: View {
8486
self.content = { viewStore in
8587
if var state = viewStore.state {
8688
return ifContent(
87-
store.scope {
88-
state = $0 ?? state
89-
return state
90-
}
89+
store
90+
.filter { state, _ in state == nil ? !BindingLocal.isActive : true }
91+
.scope {
92+
state = $0 ?? state
93+
return state
94+
}
9195
)
9296
} else {
9397
return nil

Sources/ComposableArchitecture/ViewStore.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ public final class ViewStore<ViewState, ViewAction>: ObservableObject {
447447
}
448448
}
449449
}
450+
450451
/// Derives a binding from the store that prevents direct writes to state and instead sends
451452
/// actions to the store.
452453
///
@@ -577,7 +578,11 @@ public final class ViewStore<ViewState, ViewAction>: ObservableObject {
577578
send action: HashableWrapper<(Value) -> ViewAction>
578579
) -> Value {
579580
get { state.rawValue(self.state) }
580-
set { self.send(action.rawValue(newValue)) }
581+
set {
582+
BindingLocal.$isActive.withValue(true) {
583+
self.send(action.rawValue(newValue))
584+
}
585+
}
581586
}
582587
}
583588

@@ -767,3 +772,7 @@ private struct HashableWrapper<Value>: Hashable {
767772
static func == (lhs: Self, rhs: Self) -> Bool { false }
768773
func hash(into hasher: inout Hasher) {}
769774
}
775+
776+
enum BindingLocal {
777+
@TaskLocal static var isActive = false
778+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#if DEBUG
2+
import XCTest
3+
4+
@testable import ComposableArchitecture
5+
6+
@MainActor
7+
final class BindingLocalTests: XCTestCase {
8+
public func testBindingLocalIsActive() {
9+
XCTAssertFalse(BindingLocal.isActive)
10+
11+
struct MyReducer: ReducerProtocol {
12+
struct State: Equatable {
13+
var text = ""
14+
}
15+
16+
enum Action: Equatable {
17+
case textChanged(String)
18+
}
19+
20+
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
21+
switch action {
22+
case let .textChanged(text):
23+
state.text = text
24+
return .none
25+
}
26+
}
27+
}
28+
29+
let store = Store(initialState: MyReducer.State(), reducer: MyReducer())
30+
let viewStore = ViewStore(store, observe: { $0 })
31+
32+
let binding = viewStore.binding(get: \.text) { text in
33+
XCTAssertTrue(BindingLocal.isActive)
34+
return .textChanged(text)
35+
}
36+
binding.wrappedValue = "Hello!"
37+
XCTAssertEqual(viewStore.text, "Hello!")
38+
}
39+
}
40+
#endif

Tests/ComposableArchitectureTests/StoreTests.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,4 +559,21 @@ final class StoreTests: XCTestCase {
559559

560560
XCTAssertEqual(viewStore.state, UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!)
561561
}
562+
563+
func testFilter() {
564+
let store = Store<Int?, Void>(initialState: nil, reducer: EmptyReducer())
565+
.filter { state, _ in state != nil }
566+
567+
let viewStore = ViewStore(store)
568+
var count = 0
569+
viewStore.publisher
570+
.sink { _ in count += 1 }
571+
.store(in: &self.cancellables)
572+
573+
XCTAssertEqual(count, 1)
574+
viewStore.send(())
575+
XCTAssertEqual(count, 1)
576+
viewStore.send(())
577+
XCTAssertEqual(count, 1)
578+
}
562579
}

0 commit comments

Comments
 (0)