Skip to content

Commit

Permalink
Warn if bindable store binding action isn't processed (#3347)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
stephencelis committed Sep 5, 2024
1 parent e4371a5 commit d5c2d76
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,81 @@
}
}

private final class BindableActionDebugger<Action>: Sendable {
let isInvalidated: @MainActor @Sendable () -> Bool
let value: any Sendable
let wasCalled = LockIsolated(false)
init(
value: some Sendable,
isInvalidated: @escaping @MainActor @Sendable () -> Bool
) {
self.value = value
self.isInvalidated = isInvalidated
}
deinit {
let isInvalidated = mainActorNow(execute: isInvalidated)
guard !isInvalidated else { return }
guard wasCalled.value else {
var valueDump: String {
var valueDump = ""
customDump(self.value, to: &valueDump, maxDepth: 0)
return valueDump
}
reportIssue(
"""
A binding action sent from a store was not handled. …
Action:
\(typeName(Action.self)).binding(.set(_, \(valueDump)))
To fix this, invoke "BindingReducer()" from your feature reducer's "body".
"""
)
return
}
}
}

extension BindableAction where State: ObservableState {
fileprivate static func set<Value: Equatable & Sendable>(
_ keyPath: _WritableKeyPath<State, Value>,
_ value: Value,
isInvalidated: (@MainActor @Sendable () -> Bool)?
) -> Self {
#if DEBUG
if let isInvalidated {
let debugger = BindableActionDebugger<Self>(
value: value,
isInvalidated: isInvalidated
)
return Self.binding(
.init(
keyPath: keyPath,
set: {
debugger.wasCalled.setValue(true)
$0[keyPath: keyPath] = value
},
value: value,
valueIsEqualTo: { $0 as? Value == value }
)
)
}
#endif
return Self.binding(
.init(
keyPath: keyPath,
set: { $0[keyPath: keyPath] = value },
value: value,
valueIsEqualTo: { $0 as? Value == value }
)
)
}

public static func set<Value: Equatable & Sendable>(
_ keyPath: _WritableKeyPath<State, Value>,
_ value: Value
) -> Self {
self.binding(.set(keyPath, value))
self.set(keyPath, value, isInvalidated: nil)
}
}

Expand All @@ -94,7 +163,7 @@
get { self.state[keyPath: keyPath] }
set {
BindingLocal.$isActive.withValue(true) {
self.send(.binding(.set(keyPath, newValue)))
self.send(.set(keyPath, newValue, isInvalidated: _isInvalidated))
}
}
}
Expand All @@ -111,7 +180,7 @@
get { self.observableState }
set {
BindingLocal.$isActive.withValue(true) {
self.send(.binding(.set(\.self, newValue)))
self.send(.set(\.self, newValue, isInvalidated: _isInvalidated))
}
}
}
Expand All @@ -130,7 +199,7 @@
get { self.state[keyPath: keyPath] }
set {
BindingLocal.$isActive.withValue(true) {
self.send(.view(.binding(.set(keyPath, newValue))))
self.send(.view(.set(keyPath, newValue, isInvalidated: _isInvalidated)))
}
}
}
Expand All @@ -148,7 +217,7 @@
get { self.observableState }
set {
BindingLocal.$isActive.withValue(true) {
self.send(.view(.binding(.set(\.self, newValue))))
self.send(.view(.set(\.self, newValue, isInvalidated: _isInvalidated)))
}
}
}
Expand Down
26 changes: 26 additions & 0 deletions Tests/ComposableArchitectureTests/RuntimeWarningTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,32 @@
}
}

@ObservableState
struct TestObservableBindingUnhandledActionState: Equatable {
var count = 0
}
@MainActor
func testObservableBindingUnhandledAction() {
typealias State = TestObservableBindingUnhandledActionState
enum Action: BindableAction, Equatable {
case binding(BindingAction<State>)
}
let store = Store<State, Action>(initialState: State()) {}

XCTExpectFailure {
store.count = 42
} issueMatcher: {
$0.compactDescription == """
failed - A binding action sent from a store was not handled. …
Action:
RuntimeWarningTests.Action.binding(.set(_, 42))
To fix this, invoke "BindingReducer()" from your feature reducer's "body".
"""
}
}

@MainActor
func testBindingUnhandledAction_BindingState() {
struct State: Equatable {
Expand Down

0 comments on commit d5c2d76

Please sign in to comment.