Skip to content

Commit d5c2d76

Browse files
authored
Warn if bindable store binding action isn't processed (#3347)
* Warn if bindable store binding action isn't processed Looks like the warnings we emit when we detect `BindingReducer` is missing are only applied to view stores, and were not ported over to the newer observable store bindings. This PR fixes that, though the main caveat is the messages can't seem to point to any good context. These bindings are derived from dynamic member lookup, which can't include source context like file/line. * wip * Add test
1 parent e4371a5 commit d5c2d76

File tree

2 files changed

+100
-5
lines changed

2 files changed

+100
-5
lines changed

Sources/ComposableArchitecture/Observation/Binding+Observation.swift

+74-5
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,81 @@
7878
}
7979
}
8080

81+
private final class BindableActionDebugger<Action>: Sendable {
82+
let isInvalidated: @MainActor @Sendable () -> Bool
83+
let value: any Sendable
84+
let wasCalled = LockIsolated(false)
85+
init(
86+
value: some Sendable,
87+
isInvalidated: @escaping @MainActor @Sendable () -> Bool
88+
) {
89+
self.value = value
90+
self.isInvalidated = isInvalidated
91+
}
92+
deinit {
93+
let isInvalidated = mainActorNow(execute: isInvalidated)
94+
guard !isInvalidated else { return }
95+
guard wasCalled.value else {
96+
var valueDump: String {
97+
var valueDump = ""
98+
customDump(self.value, to: &valueDump, maxDepth: 0)
99+
return valueDump
100+
}
101+
reportIssue(
102+
"""
103+
A binding action sent from a store was not handled. …
104+
105+
Action:
106+
\(typeName(Action.self)).binding(.set(_, \(valueDump)))
107+
108+
To fix this, invoke "BindingReducer()" from your feature reducer's "body".
109+
"""
110+
)
111+
return
112+
}
113+
}
114+
}
115+
81116
extension BindableAction where State: ObservableState {
117+
fileprivate static func set<Value: Equatable & Sendable>(
118+
_ keyPath: _WritableKeyPath<State, Value>,
119+
_ value: Value,
120+
isInvalidated: (@MainActor @Sendable () -> Bool)?
121+
) -> Self {
122+
#if DEBUG
123+
if let isInvalidated {
124+
let debugger = BindableActionDebugger<Self>(
125+
value: value,
126+
isInvalidated: isInvalidated
127+
)
128+
return Self.binding(
129+
.init(
130+
keyPath: keyPath,
131+
set: {
132+
debugger.wasCalled.setValue(true)
133+
$0[keyPath: keyPath] = value
134+
},
135+
value: value,
136+
valueIsEqualTo: { $0 as? Value == value }
137+
)
138+
)
139+
}
140+
#endif
141+
return Self.binding(
142+
.init(
143+
keyPath: keyPath,
144+
set: { $0[keyPath: keyPath] = value },
145+
value: value,
146+
valueIsEqualTo: { $0 as? Value == value }
147+
)
148+
)
149+
}
150+
82151
public static func set<Value: Equatable & Sendable>(
83152
_ keyPath: _WritableKeyPath<State, Value>,
84153
_ value: Value
85154
) -> Self {
86-
self.binding(.set(keyPath, value))
155+
self.set(keyPath, value, isInvalidated: nil)
87156
}
88157
}
89158

@@ -94,7 +163,7 @@
94163
get { self.state[keyPath: keyPath] }
95164
set {
96165
BindingLocal.$isActive.withValue(true) {
97-
self.send(.binding(.set(keyPath, newValue)))
166+
self.send(.set(keyPath, newValue, isInvalidated: _isInvalidated))
98167
}
99168
}
100169
}
@@ -111,7 +180,7 @@
111180
get { self.observableState }
112181
set {
113182
BindingLocal.$isActive.withValue(true) {
114-
self.send(.binding(.set(\.self, newValue)))
183+
self.send(.set(\.self, newValue, isInvalidated: _isInvalidated))
115184
}
116185
}
117186
}
@@ -130,7 +199,7 @@
130199
get { self.state[keyPath: keyPath] }
131200
set {
132201
BindingLocal.$isActive.withValue(true) {
133-
self.send(.view(.binding(.set(keyPath, newValue))))
202+
self.send(.view(.set(keyPath, newValue, isInvalidated: _isInvalidated)))
134203
}
135204
}
136205
}
@@ -148,7 +217,7 @@
148217
get { self.observableState }
149218
set {
150219
BindingLocal.$isActive.withValue(true) {
151-
self.send(.view(.binding(.set(\.self, newValue))))
220+
self.send(.view(.set(\.self, newValue, isInvalidated: _isInvalidated)))
152221
}
153222
}
154223
}

Tests/ComposableArchitectureTests/RuntimeWarningTests.swift

+26
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,32 @@
3030
}
3131
}
3232

33+
@ObservableState
34+
struct TestObservableBindingUnhandledActionState: Equatable {
35+
var count = 0
36+
}
37+
@MainActor
38+
func testObservableBindingUnhandledAction() {
39+
typealias State = TestObservableBindingUnhandledActionState
40+
enum Action: BindableAction, Equatable {
41+
case binding(BindingAction<State>)
42+
}
43+
let store = Store<State, Action>(initialState: State()) {}
44+
45+
XCTExpectFailure {
46+
store.count = 42
47+
} issueMatcher: {
48+
$0.compactDescription == """
49+
failed - A binding action sent from a store was not handled. …
50+
51+
Action:
52+
RuntimeWarningTests.Action.binding(.set(_, 42))
53+
54+
To fix this, invoke "BindingReducer()" from your feature reducer's "body".
55+
"""
56+
}
57+
}
58+
3359
@MainActor
3460
func testBindingUnhandledAction_BindingState() {
3561
struct State: Equatable {

0 commit comments

Comments
 (0)