Skip to content

Commit d110566

Browse files
committed
Enable Action executing state feedback to the state property.
1 parent b952269 commit d110566

File tree

2 files changed

+120
-86
lines changed

2 files changed

+120
-86
lines changed

Sources/Action.swift

Lines changed: 111 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,19 @@ import Result
1818
///
1919
/// `Action` enforces serial execution, and disables the `Action` during the execution.
2020
public final class Action<Input, Output, Error: Swift.Error> {
21-
private let deinitToken: Lifetime.Token
21+
private enum Availability {
22+
case enabledIdle
23+
case enabledExecuting
24+
case disabledIdle
25+
case disabledExecuting
26+
}
2227

23-
private let executeClosure: (_ state: Any, _ input: Input) -> SignalProducer<Output, Error>
28+
private let execute: (Action<Input, Output, Error>, Input) -> SignalProducer<Output, ActionError<Error>>
2429
private let eventsObserver: Signal<Signal<Output, Error>.Event, NoError>.Observer
2530
private let disabledErrorsObserver: Signal<(), NoError>.Observer
2631

32+
private let deinitToken: Lifetime.Token
33+
2734
/// The lifetime of the `Action`.
2835
public let lifetime: Lifetime
2936

@@ -37,13 +44,17 @@ public final class Action<Input, Output, Error: Swift.Error> {
3744
///
3845
/// In other words, this sends every value from every unit of work that the `Action`
3946
/// executes.
40-
public let values: Signal<Output, NoError>
47+
public private(set) lazy var values: Signal<Output, NoError> = { [unowned self] in
48+
return self.events.filterMap { $0.value }
49+
}()
4150

4251
/// A signal of all errors generated from all units of work of the `Action`.
4352
///
4453
/// In other words, this sends every error from every unit of work that the `Action`
4554
/// executes.
46-
public let errors: Signal<Error, NoError>
55+
public private(set) lazy var errors: Signal<Error, NoError> = { [unowned self] in
56+
return self.events.filterMap { $0.error }
57+
}()
4758

4859
/// A signal of all failed attempts to start a unit of work of the `Action`.
4960
public let disabledErrors: Signal<(), NoError>
@@ -52,16 +63,16 @@ public final class Action<Input, Output, Error: Swift.Error> {
5263
///
5364
/// In other words, this will send completed events from every signal generated
5465
/// by each SignalProducer returned from apply().
55-
public let completed: Signal<(), NoError>
66+
public private(set) lazy var completed: Signal<(), NoError> = { [unowned self] in
67+
return self.events.filter { $0.isCompleted }.map { _ in }
68+
}()
5669

5770
/// Whether the action is currently executing.
5871
public let isExecuting: Property<Bool>
5972

6073
/// Whether the action is currently enabled.
6174
public let isEnabled: Property<Bool>
6275

63-
private let state: MutableProperty<ActionState>
64-
6576
/// Initializes an `Action` that would be conditionally enabled depending on its
6677
/// state.
6778
///
@@ -80,39 +91,110 @@ public final class Action<Input, Output, Error: Swift.Error> {
8091
///
8192
/// - parameters:
8293
/// - state: A property to be the state of the `Action`.
83-
/// - enabledIf: A predicate which determines the availability of the `Action`,
94+
/// - isEnabled: A predicate which determines the availability of the `Action`,
8495
/// given the latest `Action` state.
8596
/// - execute: A closure that produces a unit of work, as `SignalProducer`, to be
8697
/// executed by the `Action`.
87-
public init<State: PropertyProtocol>(state property: State, enabledIf isEnabled: @escaping (State.Value) -> Bool, execute: @escaping (State.Value, Input) -> SignalProducer<Output, Error>) {
98+
public init<State: PropertyProtocol>(state: State, enabledIf isEnabled: @escaping (State.Value) -> Bool, execute: @escaping (State.Value, Input) -> SignalProducer<Output, Error>) {
99+
typealias ActionState = (availability: Availability, value: State.Value)
100+
let isUserEnabled = isEnabled
101+
88102
deinitToken = Lifetime.Token()
89103
lifetime = Lifetime(deinitToken)
90-
91-
// Retain the `property` for the created `Action`.
92-
lifetime.observeEnded { _ = property }
93104

94-
executeClosure = { state, input in execute(state as! State.Value, input) }
105+
// `isExecuting` and `isEnabled` uses their own locks, so that legitimate
106+
// feedbacks would not deadlock.
107+
let initial = (availability: Availability.enabledIdle, value: state.value)
108+
let actionState = MutableProperty<ActionState>(initial)
109+
let isExecuting = MutableProperty(false)
110+
let isEnabled = MutableProperty(true)
111+
self.isExecuting = Property(capturing: isExecuting)
112+
self.isEnabled = Property(capturing: isEnabled)
113+
114+
// Associate the state property with the created `Action`.
115+
lifetime.observeEnded { _ = state }
95116

96117
(events, eventsObserver) = Signal<Signal<Output, Error>.Event, NoError>.pipe()
97118
(disabledErrors, disabledErrorsObserver) = Signal<(), NoError>.pipe()
98119

99-
values = events.filterMap { $0.value }
100-
errors = events.filterMap { $0.error }
101-
completed = events.filter { $0.isCompleted }.map { _ in }
120+
let disposable = state.producer.startWithValues { value in
121+
actionState.modify { state in
122+
state.value = value
123+
124+
if isUserEnabled(value) {
125+
switch state.availability {
126+
case .enabledIdle, .enabledExecuting:
127+
break
128+
129+
case .disabledIdle:
130+
state.availability = .enabledIdle
131+
isEnabled.value = true
102132

103-
let initial = ActionState(value: property.value, isEnabled: { isEnabled($0 as! State.Value) })
104-
state = MutableProperty(initial)
133+
case .disabledExecuting:
134+
state.availability = .enabledExecuting
135+
}
136+
} else {
137+
switch state.availability {
138+
case .disabledIdle, .disabledExecuting:
139+
break
140+
141+
case .enabledIdle:
142+
state.availability = .disabledIdle
143+
isEnabled.value = false
105144

106-
property.signal
107-
.take(during: state.lifetime)
108-
.observeValues { [weak state] newValue in
109-
state?.modify {
110-
$0.value = newValue
145+
case .enabledExecuting:
146+
state.availability = .disabledExecuting
147+
}
111148
}
112149
}
150+
}
113151

114-
self.isEnabled = state.map { $0.isEnabled }.skipRepeats()
115-
self.isExecuting = state.map { $0.isExecuting }.skipRepeats()
152+
lifetime.observeEnded(disposable.dispose)
153+
154+
self.execute = { action, input in
155+
return SignalProducer { observer, disposable in
156+
func tryStart() -> State.Value? {
157+
return actionState.modify { state in
158+
guard state.availability == .enabledIdle else {
159+
return nil
160+
}
161+
162+
state.availability = .enabledExecuting
163+
isExecuting.value = true
164+
isEnabled.value = false
165+
return state.value
166+
}
167+
}
168+
169+
guard let state = tryStart() else {
170+
observer.send(error: .disabled)
171+
action.disabledErrorsObserver.send(value: ())
172+
return
173+
}
174+
175+
disposable += execute(state, input).start { event in
176+
observer.action(event.mapError(ActionError.producerFailed))
177+
action.eventsObserver.send(value: event)
178+
}
179+
180+
disposable.add {
181+
actionState.modify { state in
182+
switch state.availability {
183+
case .disabledIdle, .enabledIdle:
184+
fatalError("The `Action` should not be idle at this point.")
185+
186+
case .disabledExecuting:
187+
state.availability = .disabledIdle
188+
189+
case .enabledExecuting:
190+
state.availability = .enabledIdle
191+
isEnabled.value = true
192+
}
193+
isExecuting.value = false
194+
}
195+
}
196+
}
197+
}
116198
}
117199

118200
/// Initializes an `Action` that would be conditionally enabled.
@@ -122,11 +204,11 @@ public final class Action<Input, Output, Error: Swift.Error> {
122204
/// `execute` with the input value.
123205
///
124206
/// - parameters:
125-
/// - enabledIf: A property which determines the availability of the `Action`.
207+
/// - isEnabled: A property which determines the availability of the `Action`.
126208
/// - execute: A closure that produces a unit of work, as `SignalProducer`, to be
127209
/// executed by the `Action`.
128-
public convenience init<P: PropertyProtocol>(enabledIf property: P, execute: @escaping (Input) -> SignalProducer<Output, Error>) where P.Value == Bool {
129-
self.init(state: property, enabledIf: { $0 }) { _, input in
210+
public convenience init<P: PropertyProtocol>(enabledIf isEnabled: P, execute: @escaping (Input) -> SignalProducer<Output, Error>) where P.Value == Bool {
211+
self.init(state: isEnabled, enabledIf: { $0 }) { _, input in
130212
execute(input)
131213
}
132214
}
@@ -162,62 +244,7 @@ public final class Action<Input, Output, Error: Swift.Error> {
162244
/// - returns: A producer that forwards events generated by its started unit of work,
163245
/// or emits `ActionError.disabled` if the execution attempt is failed.
164246
public func apply(_ input: Input) -> SignalProducer<Output, ActionError<Error>> {
165-
return SignalProducer { observer, disposable in
166-
let startingState = self.state.modify { state -> Any? in
167-
if state.isEnabled {
168-
state.isExecuting = true
169-
return state.value
170-
} else {
171-
return nil
172-
}
173-
}
174-
175-
guard let state = startingState else {
176-
observer.send(error: .disabled)
177-
self.disabledErrorsObserver.send(value: ())
178-
return
179-
}
180-
181-
self.executeClosure(state, input).startWithSignal { signal, signalDisposable in
182-
disposable += signalDisposable
183-
184-
signal.observe { event in
185-
observer.action(event.mapError(ActionError.producerFailed))
186-
self.eventsObserver.send(value: event)
187-
}
188-
}
189-
190-
disposable += {
191-
self.state.modify {
192-
$0.isExecuting = false
193-
}
194-
}
195-
}
196-
}
197-
}
198-
199-
private struct ActionState {
200-
var isExecuting: Bool = false
201-
202-
var value: Any {
203-
didSet {
204-
userEnabled = userEnabledClosure(value)
205-
}
206-
}
207-
208-
private var userEnabled: Bool
209-
private let userEnabledClosure: (Any) -> Bool
210-
211-
init(value: Any, isEnabled: @escaping (Any) -> Bool) {
212-
self.value = value
213-
self.userEnabled = isEnabled(value)
214-
self.userEnabledClosure = isEnabled
215-
}
216-
217-
/// Whether the action should be enabled for the given combination of user
218-
/// enabledness and executing status.
219-
fileprivate var isEnabled: Bool {
220-
return userEnabled && !isExecuting
247+
return execute(self, input)
221248
}
222249
}
223250

Tests/ReactiveSwiftTests/ActionSpec.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ class ActionSpec: QuickSpec {
4343
observer.send(value: "\(number)")
4444
observer.send(value: "\(number)\(number)")
4545

46-
scheduler.schedule {
46+
disposable += scheduler.schedule {
4747
observer.sendCompleted()
4848
}
4949
} else {
50-
scheduler.schedule {
50+
disposable += scheduler.schedule {
5151
observer.send(error: testError)
5252
}
5353
}
@@ -114,6 +114,13 @@ class ActionSpec: QuickSpec {
114114
expect(action.isExecuting.value) == false
115115
}
116116

117+
it("should not deadlock when its executing state affects its state property without constituting a feedback loop") {
118+
enabled <~ action.isExecuting.negate()
119+
120+
let disposable = action.apply(0).start()
121+
disposable.dispose()
122+
}
123+
117124
it("should not deadlock") {
118125
final class ViewModel {
119126
let action2 = Action<(), (), NoError> { SignalProducer(value: ()) }

0 commit comments

Comments
 (0)