Skip to content

Commit 9f9401c

Browse files
committed
Expand feedback support to Action.isEnabled.
1 parent a1124c1 commit 9f9401c

File tree

4 files changed

+111
-47
lines changed

4 files changed

+111
-47
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# master
22
*Please add new entries at the top.*
33

4+
1. Feedbacks from `isEnabled` to the state of the same `Action` no longer deadlocks if it does not constitute an infinite feedback loop. (#481, kudos to @andersio)
5+
6+
Note that `isExecuting` already supports `Action` state feedback, and legitimate feedback loops would still deadlock.
7+
48
# 2.0.0-rc.2
59
1. Fixed a deadlock upon disposal when combining operators, i.e. `zip` and `combineLatest`, are used. (#471, kudos to @stevebrambilla for catching the bug)
610

Sources/Action.swift

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,6 @@ public final class Action<Input, Output, Error: Swift.Error> {
9898
deinitToken = Lifetime.Token()
9999
lifetime = Lifetime(deinitToken)
100100

101-
let actionState = MutableProperty(ActionState<State.Value>(isUserEnabled: true, isExecuting: false, value: state.value))
102-
self.isEnabled = actionState.map { $0.isEnabled }
103-
104-
// `isExecuting` has its own backing so that when the observer of `isExecuting`
105-
// synchronously affects the action state, the signal of the action state does not
106-
// deadlock due to the recursion.
107-
let isExecuting = MutableProperty(false)
108-
self.isExecuting = Property(capturing: isExecuting)
109-
110101
// `Action` retains its state property.
111102
lifetime.observeEnded { _ = state }
112103

@@ -117,8 +108,34 @@ public final class Action<Input, Output, Error: Swift.Error> {
117108
errors = events.filterMap { $0.error }
118109
completed = events.filterMap { $0.isCompleted ? () : nil }
119110

111+
let actionState = MutableProperty(ActionState<State.Value>(isUserEnabled: true, isExecuting: false, value: state.value))
112+
113+
// `isEnabled` and `isExecuting` have their own backing so that when the observers
114+
// of these synchronously affects the action state, the signal of the action state
115+
// does not deadlock due to the recursion.
116+
let isExecuting = MutableProperty(false)
117+
self.isExecuting = Property(capturing: isExecuting)
118+
let isEnabled = MutableProperty(actionState.value.isEnabled)
119+
self.isEnabled = Property(capturing: isEnabled)
120+
121+
func modifyActionState<Result>(_ action: (inout ActionState<State.Value>) throws -> Result) rethrows -> Result {
122+
return try actionState.begin { storage in
123+
let oldState = storage.value
124+
defer {
125+
let newState = storage.value
126+
if oldState.isEnabled != newState.isEnabled {
127+
isEnabled.value = newState.isEnabled
128+
}
129+
if oldState.isExecuting != newState.isExecuting {
130+
isExecuting.value = newState.isExecuting
131+
}
132+
}
133+
return try storage.modify(action)
134+
}
135+
}
136+
120137
let disposable = state.producer.startWithValues { value in
121-
actionState.modify { state in
138+
modifyActionState { state in
122139
state.value = value
123140
state.isUserEnabled = isUserEnabled(value)
124141
}
@@ -128,21 +145,12 @@ public final class Action<Input, Output, Error: Swift.Error> {
128145

129146
self.execute = { action, input in
130147
return SignalProducer { observer, lifetime in
131-
var notifiesExecutionState = false
132-
133-
func didSet() {
134-
if notifiesExecutionState {
135-
isExecuting.value = true
136-
}
137-
}
138-
139-
let latestState: State.Value? = actionState.modify(didSet: didSet) { state in
148+
let latestState: State.Value? = modifyActionState { state in
140149
guard state.isEnabled else {
141150
return nil
142151
}
143152

144153
state.isExecuting = true
145-
notifiesExecutionState = true
146154
return state.value
147155
}
148156

@@ -159,10 +167,7 @@ public final class Action<Input, Output, Error: Swift.Error> {
159167

160168
lifetime.observeEnded {
161169
interruptHandle.dispose()
162-
163-
actionState.modify(didSet: { isExecuting.value = false }) { state in
164-
state.isExecuting = false
165-
}
170+
modifyActionState { $0.isExecuting = false }
166171
}
167172
}
168173
}

Sources/Property.swift

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -554,10 +554,13 @@ public final class Property<Value>: PropertyProtocol {
554554
return observer.action(event)
555555
}
556556

557-
box.modify(didSet: { _ in observer.action(event) }) { value in
558-
if let newValue = event.value {
559-
value = newValue
557+
box.begin { storage in
558+
storage.modify { value in
559+
if let newValue = event.value {
560+
value = newValue
561+
}
560562
}
563+
observer.action(event)
561564
}
562565
}
563566
}
@@ -675,31 +678,28 @@ public final class MutableProperty<Value>: ComposableMutablePropertyProtocol {
675678
/// Atomically modifies the variable.
676679
///
677680
/// - parameters:
678-
/// - action: A closure that accepts old property value and returns a new
679-
/// property value.
681+
/// - action: A closure that accepts an inout reference to the value.
680682
///
681683
/// - returns: The result of the action.
682684
@discardableResult
683685
public func modify<Result>(_ action: (inout Value) throws -> Result) rethrows -> Result {
684-
return try box.modify(didSet: { self.observer.send(value: $0) }) { value in
685-
return try action(&value)
686+
return try box.begin { storage in
687+
defer { observer.send(value: storage.value) }
688+
return try storage.modify(action)
686689
}
687690
}
688691

689692
/// Atomically modifies the variable.
690693
///
694+
/// - warning: The reference should not be escaped.
695+
///
691696
/// - parameters:
692-
/// - didSet: A closure that is invoked after `action` returns and the value is
693-
/// committed to the storage, but before `modify` releases the lock.
694-
/// - action: A closure that accepts old property value and returns a new
695-
/// property value.
697+
/// - action: A closure that accepts a reference to the property storage.
696698
///
697699
/// - returns: The result of the action.
698700
@discardableResult
699-
internal func modify<Result>(didSet: () -> Void, _ action: (inout Value) throws -> Result) rethrows -> Result {
700-
return try box.modify(didSet: { self.observer.send(value: $0); didSet() }) { value in
701-
return try action(&value)
702-
}
701+
internal func begin<Result>(_ action: (PropertyStorage<Value>) throws -> Result) rethrows -> Result {
702+
return try box.begin(action)
703703
}
704704

705705
/// Atomically performs an arbitrary action using the current value of the
@@ -719,16 +719,40 @@ public final class MutableProperty<Value>: ComposableMutablePropertyProtocol {
719719
}
720720
}
721721

722+
internal struct PropertyStorage<Value> {
723+
private unowned let box: PropertyBox<Value>
724+
725+
var value: Value {
726+
return box._value
727+
}
728+
729+
func modify<Result>(_ action: (inout Value) throws -> Result) rethrows -> Result {
730+
guard !box.isModifying else { fatalError("Nested modifications violate exclusivity of access.") }
731+
box.isModifying = true
732+
defer { box.isModifying = false }
733+
return try action(&box._value)
734+
}
735+
736+
fileprivate init(_ box: PropertyBox<Value>) {
737+
self.box = box
738+
}
739+
}
740+
722741
/// A reference counted box which holds a recursive lock and a value storage.
723742
///
724743
/// The requirement of a `Value?` storage from composed properties prevents further
725744
/// implementation sharing with `MutableProperty`.
726745
private final class PropertyBox<Value> {
746+
727747
private let lock: Lock.PthreadLock
728-
private var _value: Value
729-
private var isModifying = false
748+
fileprivate var _value: Value
749+
fileprivate var isModifying = false
730750

731-
var value: Value { return modify { $0 } }
751+
internal var value: Value {
752+
lock.lock()
753+
defer { lock.unlock() }
754+
return _value
755+
}
732756

733757
init(_ value: Value) {
734758
_value = value
@@ -741,11 +765,9 @@ private final class PropertyBox<Value> {
741765
return try action(_value)
742766
}
743767

744-
func modify<Result>(didSet: (Value) -> Void = { _ in }, _ action: (inout Value) throws -> Result) rethrows -> Result {
768+
func begin<Result>(_ action: (PropertyStorage<Value>) throws -> Result) rethrows -> Result {
745769
lock.lock()
746-
guard !isModifying else { fatalError("Nested modifications violate exclusivity of access.") }
747-
isModifying = true
748-
defer { isModifying = false; didSet(_value); lock.unlock() }
749-
return try action(&_value)
770+
defer { lock.unlock() }
771+
return try action(PropertyStorage(self))
750772
}
751773
}

Tests/ReactiveSwiftTests/ActionSpec.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,39 @@ class ActionSpec: QuickSpec {
131131
expect(action.isExecuting.value) == false
132132
}
133133

134+
it("should not deadlock when its enabled state affects its state property without constituting a feedback loop") {
135+
// Emulate control binding: When a UITextField is the first responder and
136+
// is being disabled by an `Action`, the control events emitted might
137+
// feedback into the availability of the `Action` synchronously, e.g.
138+
// via a `MutableProperty` or `ValidatingProperty`.
139+
var isFirstResponder = false
140+
141+
action.isEnabled.producer
142+
.filterMap { isActionEnabled in !isActionEnabled && isFirstResponder ? () : nil }
143+
.startWithValues { _ in enabled.value = false }
144+
145+
enabled.value = true
146+
expect(enabled.value) == true
147+
expect(action.isEnabled.value) == true
148+
expect(action.isExecuting.value) == false
149+
150+
isFirstResponder = true
151+
let disposable = action.apply(0).start()
152+
expect(enabled.value) == false
153+
expect(action.isEnabled.value) == false
154+
expect(action.isExecuting.value) == true
155+
156+
disposable.dispose()
157+
expect(enabled.value) == false
158+
expect(action.isEnabled.value) == false
159+
expect(action.isExecuting.value) == false
160+
161+
enabled.value = true
162+
expect(enabled.value) == true
163+
expect(action.isEnabled.value) == true
164+
expect(action.isExecuting.value) == false
165+
}
166+
134167
it("should not deadlock") {
135168
final class ViewModel {
136169
let action2 = Action<(), (), NoError> { _ in SignalProducer(value: ()) }

0 commit comments

Comments
 (0)