Skip to content

Commit 3cdc88a

Browse files
authored
Merge pull request #6 from ReactiveCocoa/anders/init-change
Feedback designated initializer changes + `Feedback(source:as:)`.
2 parents f75acb4 + d0325f3 commit 3cdc88a

File tree

8 files changed

+296
-86
lines changed

8 files changed

+296
-86
lines changed

Loop.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
5898B6D11F97ADDD005EEAEC /* SystemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5898B6D01F97ADDD005EEAEC /* SystemTests.swift */; };
3535
5BC88F842469CBA300394C63 /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BC88F832469CBA300394C63 /* Nimble.framework */; };
3636
5BC88F862469CBAB00394C63 /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BC88F852469CBAB00394C63 /* Nimble.framework */; };
37+
5BC88F88246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC88F87246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift */; };
38+
5BC88F89246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC88F87246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift */; };
39+
5BC88F8A246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC88F87246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift */; };
3740
5BC88F8E246B11DE00394C63 /* LoopBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC88F8D246B11DE00394C63 /* LoopBox.swift */; };
3841
5BC88F8F246B11DE00394C63 /* LoopBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC88F8D246B11DE00394C63 /* LoopBox.swift */; };
3942
5BC88F90246B11DE00394C63 /* LoopBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC88F8D246B11DE00394C63 /* LoopBox.swift */; };
@@ -191,6 +194,7 @@
191194
5898B6D01F97ADDD005EEAEC /* SystemTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemTests.swift; sourceTree = "<group>"; };
192195
5BC88F832469CBA300394C63 /* Nimble.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Nimble.framework; sourceTree = BUILT_PRODUCTS_DIR; };
193196
5BC88F852469CBAB00394C63 /* Nimble.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Nimble.framework; sourceTree = BUILT_PRODUCTS_DIR; };
197+
5BC88F87246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReactiveSwift+EnqueueTo.swift"; sourceTree = "<group>"; };
194198
5BC88F8D246B11DE00394C63 /* LoopBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopBox.swift; sourceTree = "<group>"; };
195199
5BC88F91246B17B200394C63 /* Context.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Context.swift; sourceTree = "<group>"; };
196200
5BC88F97246B191200394C63 /* Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Loop.swift; sourceTree = "<group>"; };
@@ -400,6 +404,7 @@
400404
5BC88F91246B17B200394C63 /* Context.swift */,
401405
585CD87A239E6A39004BE9CC /* Reducer.swift */,
402406
656A9C9623D0826100EFB2F8 /* FeedbackEventConsumer.swift */,
407+
5BC88F87246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift */,
403408
);
404409
path = Public;
405410
sourceTree = "<group>";
@@ -722,6 +727,7 @@
722727
5BC88F92246B17B200394C63 /* Context.swift in Sources */,
723728
585CD87B239E6A39004BE9CC /* Reducer.swift in Sources */,
724729
9AD5D42D1F97375E00E6AE5A /* Property+System.swift in Sources */,
730+
5BC88F88246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift in Sources */,
725731
656A9C9323D0813500EFB2F8 /* FeedbackLoop.swift in Sources */,
726732
656A9C9723D0826100EFB2F8 /* FeedbackEventConsumer.swift in Sources */,
727733
5BC88F8E246B11DE00394C63 /* LoopBox.swift in Sources */,
@@ -760,6 +766,7 @@
760766
5BC88F93246B17B200394C63 /* Context.swift in Sources */,
761767
585CD87C239E6A3E004BE9CC /* Reducer.swift in Sources */,
762768
65F8C262218371A800924657 /* Property+System.swift in Sources */,
769+
5BC88F89246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift in Sources */,
763770
656A9C9423D0813500EFB2F8 /* FeedbackLoop.swift in Sources */,
764771
656A9C9823D0826100EFB2F8 /* FeedbackEventConsumer.swift in Sources */,
765772
5BC88F8F246B11DE00394C63 /* LoopBox.swift in Sources */,
@@ -779,6 +786,7 @@
779786
5BC88F94246B17B200394C63 /* Context.swift in Sources */,
780787
585CD87D239E6A3E004BE9CC /* Reducer.swift in Sources */,
781788
65F8C271218371AC00924657 /* Property+System.swift in Sources */,
789+
5BC88F8A246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift in Sources */,
782790
656A9C9523D0813500EFB2F8 /* FeedbackLoop.swift in Sources */,
783791
656A9C9923D0826100EFB2F8 /* FeedbackEventConsumer.swift in Sources */,
784792
5BC88F90246B11DE00394C63 /* LoopBox.swift in Sources */,

Loop/Floodgate.swift

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,42 @@ final class Floodgate<State, Event>: FeedbackEventConsumer<Event> {
1313

1414
let (stateDidChange, changeObserver) = Signal<State, Never>.pipe()
1515

16+
/// Replay the current value, and then publish the subsequent changes.
17+
var producer: SignalProducer<State, Never> {
18+
SignalProducer { observer, lifetime in
19+
self.withValue { initial, hasStarted -> Void in
20+
observer.send(value: initial)
21+
lifetime += self.stateDidChange.observe(observer)
22+
}
23+
}
24+
}
25+
1626
private let reducerLock = NSLock()
1727
private var state: State
1828
private var hasStarted = false
1929

2030
private let queue = Atomic(QueueState())
2131
private let reducer: (inout State, Event) -> Void
32+
private let feedbackDisposables = CompositeDisposable()
2233

2334
init(state: State, reducer: @escaping (inout State, Event) -> Void) {
2435
self.state = state
2536
self.reducer = reducer
2637
}
2738

28-
func bootstrap() {
29-
reducerLock.lock()
30-
defer { reducerLock.unlock() }
31-
32-
guard !hasStarted else { return }
39+
deinit {
40+
dispose()
41+
}
3342

34-
hasStarted = true
43+
func bootstrap(with feedbacks: [FeedbackLoop<State, Event>.Feedback]) {
44+
for feedback in feedbacks {
45+
// Pass `producer` which has replay-1 semantic.
46+
feedbackDisposables += feedback.events(producer, self)
47+
}
3548

36-
changeObserver.send(value: state)
37-
drainEvents()
49+
reducerLock.perform {
50+
drainEvents()
51+
}
3852
}
3953

4054
override func process(_ event: Event, for token: Token) {
@@ -77,8 +91,14 @@ final class Floodgate<State, Event>: FeedbackEventConsumer<Event> {
7791
}
7892

7993
func dispose() {
80-
queue.modify {
94+
let shouldDisposeFeedbacks: Bool = queue.modify {
95+
let old = $0.isOuterLifetimeEnded
8196
$0.isOuterLifetimeEnded = true
97+
return old == false
98+
}
99+
100+
if shouldDisposeFeedbacks {
101+
feedbackDisposables.dispose()
82102
}
83103
}
84104

@@ -105,16 +125,3 @@ final class Floodgate<State, Event>: FeedbackEventConsumer<Event> {
105125
changeObserver.send(value: state)
106126
}
107127
}
108-
109-
extension SignalProducer where Error == Never {
110-
public func enqueue(to consumer: FeedbackEventConsumer<Value>) -> SignalProducer<Never, Never> {
111-
SignalProducer<Never, Never> { observer, lifetime in
112-
let token = Token()
113-
114-
lifetime += self.startWithValues { event in
115-
consumer.process(event, for: token)
116-
}
117-
lifetime.observeEnded { consumer.dequeueAllEvents(for: token) }
118-
}
119-
}
120-
}

Loop/LoopBox.swift

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -50,40 +50,18 @@ internal class RootLoopBox<State, Event>: LoopBoxBase<State, Event> {
5050
private let input = Loop<State, Event>.Feedback.input
5151

5252
override var producer: SignalProducer<State, Never> {
53-
SignalProducer { observer, lifetime in
54-
self.floodgate.withValue { initial, hasStarted -> Void in
55-
if hasStarted {
56-
// The feedback loop has started already, so the initial value has to be manually delivered.
57-
// Uninitialized feedback loop that does not start immediately will emit the initial state
58-
// when `start()` is called.
59-
observer.send(value: initial)
60-
}
61-
62-
lifetime += self.floodgate.stateDidChange.observe(observer)
63-
}
64-
}
53+
floodgate.producer
6554
}
6655

6756
init(
6857
initial: State,
69-
reducer: @escaping (inout State, Event) -> Void,
70-
feedbacks: [Loop<State, Event>.Feedback],
71-
startImmediately: Bool
58+
reducer: @escaping (inout State, Event) -> Void
7259
) {
7360
(_lifetime, token) = Lifetime.make()
7461
floodgate = Floodgate<State, Event>(state: initial, reducer: reducer)
7562
_lifetime.observeEnded(floodgate.dispose)
7663

77-
for feedback in feedbacks + [input.feedback] {
78-
_lifetime += feedback
79-
.events(floodgate.stateDidChange.producer, floodgate)
80-
}
81-
8264
super.init()
83-
84-
if startImmediately {
85-
self.start()
86-
}
8765
}
8866

8967
override func scoped<S, E>(
@@ -93,8 +71,8 @@ internal class RootLoopBox<State, Event>: LoopBoxBase<State, Event> {
9371
ScopedLoopBox(root: self, value: scope, event: event)
9472
}
9573

96-
func start() {
97-
floodgate.bootstrap()
74+
func start(with feedbacks: [Loop<State, Event>.Feedback]) {
75+
floodgate.bootstrap(with: feedbacks)
9876
}
9977

10078
func stop() {

Loop/Public/FeedbackLoop.swift

Lines changed: 111 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,102 @@ extension Loop {
44
public struct Feedback {
55
let events: (_ state: SignalProducer<State, Never>, _ output: FeedbackEventConsumer<Event>) -> Disposable
66

7-
public init(
8-
events: @escaping (
9-
_ state: SignalProducer<State, Never>,
10-
_ output: FeedbackEventConsumer<Event>
11-
) -> Disposable
7+
/// Private designated initializer. See the public designated initializer below.
8+
fileprivate init(
9+
startWith events: @escaping (_ state: SignalProducer<State, Never>, _ output: FeedbackEventConsumer<Event>) -> Disposable
1210
) {
1311
self.events = events
1412
}
1513

1614
/// Creates a custom Feedback, with the complete liberty of defining the data flow.
1715
///
18-
/// - important: While you may respond to state changes in whatever ways you prefer, you **must** enqueue produced
19-
/// events using the `SignalProducer.enqueue(to:)` operator to the `FeedbackEventConsumer` provided
20-
/// to you. Otherwise, the feedback loop will not be able to pick up and process your events.
16+
/// Consider using the standard `Feedback` variants, before deriving down to use this desginated initializer.
17+
///
18+
/// Events must be explicitly enqueued using `SignalProducer.enqueue(to:)` with the `FeedbackEventConsumer`
19+
/// provided to the setup closure. `enqueue(to:)` respects producer cancellation and removes outstanding events
20+
/// from the loop internal event queue.
21+
///
22+
/// This is useful if you wish to discard events when the state changes in certain ways. For example,
23+
/// `Feedback(skippingRepeated:effects:)` enqueues events inside `flatMap(.latest)`, so that unprocessed events
24+
/// are automatically removed when the inner producer has switched.
25+
///
26+
/// ## State producer in the `setup` closure
27+
/// The setup closure provides you a `state` producer — it replays the latest state at starting time, and then
28+
/// publishes all state changes.
29+
///
30+
/// Loop guarantees only that this `state` producer is **eventually consistent** with events emitted by your
31+
/// feedback. This means you should not make any strong assumptions on events you enqueued being immediately
32+
/// reflected by `state`.
33+
///
34+
/// For example, if you start the `state` producer again, synchronously after enqueuing an event, the event
35+
/// may not have been processed yet, and therefore the assertion would fail:
36+
/// ```swift
37+
/// Feedback { state, output in
38+
/// state
39+
/// .filter { $0.apples.isEmpty == false }
40+
/// .map(value: Event.eatAllApples)
41+
/// .take(first: 1)
42+
/// .concat(
43+
/// state
44+
/// .take(first: 1)
45+
/// .on(value: { state in
46+
/// guard state.apples.isEmpty else { return }
47+
///
48+
/// // ❌🙅‍♀️ No guarantee that this is true.
49+
/// fatalError("It should have eaten all the apples!")
50+
/// })
51+
/// )
52+
/// .enqueue(to: output)
53+
/// }
54+
/// ```
55+
///
56+
/// You can however expect it to be eventually consistent:
57+
/// ```swift
58+
/// Feedback { state, output in
59+
/// state
60+
/// .filter { $0.apples.isEmpty == false }
61+
/// .map(value: Event.eatAllApples)
62+
/// .take(first: 1)
63+
/// .concat(
64+
/// state
65+
/// .filter { $0.apples.isEmpty } // ℹ️ Watching specifically for the ideal state.
66+
/// .take(first: 1)
67+
/// .on(value: { state in
68+
/// guard state.apples.isEmpty else { return }
69+
///
70+
/// // ✅👍 We would eventually observe this, when the loop event queue
71+
/// // has caught up with `.eatAppleApples` we enqueued earlier.
72+
/// fatalError("It should have eaten all the apples!")
73+
/// })
74+
/// )
75+
/// .enqueue(to: output)
76+
/// }
77+
/// ```
2178
///
2279
/// - parameters:
2380
/// - setup: The setup closure to construct a data flow producing events in respond to changes from `state`,
2481
/// and having them consumed by `output` using the `SignalProducer.enqueue(to:)` operator.
25-
public static func custom(
26-
_ setup: @escaping (
82+
public init(
83+
events: @escaping (
2784
_ state: SignalProducer<State, Never>,
2885
_ output: FeedbackEventConsumer<Event>
29-
) -> Disposable
30-
) -> Feedback {
31-
return Feedback(events: setup)
86+
) -> SignalProducer<Never, Never>
87+
) {
88+
self.events = { events($0, $1).start() }
89+
}
90+
91+
/// Creates a Feedback that observes an external producer and maps it to an event.
92+
///
93+
/// - parameters:
94+
/// - setup: The setup closure to construct a data flow producing events in respond to changes from `state`,
95+
/// and having them consumed by `output` using the `SignalProducer.enqueue(to:)` operator.
96+
public init<Values: SignalProducerConvertible>(
97+
source: Values,
98+
as transform: @escaping (Values.Value) -> Event
99+
) where Values.Error == Never {
100+
self.init { _, output in
101+
source.producer.map(transform).enqueueNonCancelling(to: output)
102+
}
32103
}
33104

34105
/// Creates a Feedback which re-evaluates the given effect every time the
@@ -133,9 +204,7 @@ extension Loop {
133204

134205
public static var input: (feedback: Feedback, observer: (Event) -> Void) {
135206
let pipe = Signal<Event, Never>.pipe()
136-
let feedback = Feedback.custom { (state, consumer) -> Disposable in
137-
pipe.output.producer.enqueue(to: consumer).start()
138-
}
207+
let feedback = Feedback(source: pipe.output, as: { $0 })
139208
return (feedback, pipe.input.send)
140209
}
141210

@@ -144,23 +213,45 @@ extension Loop {
144213
value: KeyPath<State, LocalState>,
145214
event: @escaping (LocalEvent) -> Event
146215
) -> Feedback {
147-
return Feedback.custom { (state, consumer) -> Disposable in
216+
return Feedback(startWith: { (state, consumer) in
148217
return feedback.events(
149218
state.map(value),
150219
consumer.pullback(event)
151220
)
152-
}
221+
})
153222
}
154223

155224
public static func combine(_ feedbacks: Loop<State, Event>.Feedback...) -> Feedback {
156-
return .custom { (state, consumer) -> Disposable in
225+
return Feedback(startWith: { (state, consumer) in
157226
return feedbacks.map { (feedback) in
158227
feedback.events(state, consumer)
159228
}
160229
.reduce(into: CompositeDisposable()) { (composite, disposable) in
161230
composite += disposable
162231
}
163-
}
232+
})
164233
}
165234
}
166235
}
236+
237+
extension Loop.Feedback {
238+
@available(*, deprecated, renamed:"init(_:)")
239+
public static func custom(
240+
_ setup: @escaping (
241+
_ state: SignalProducer<State, Never>,
242+
_ output: FeedbackEventConsumer<Event>
243+
) -> Disposable
244+
) -> Loop.Feedback {
245+
return FeedbackLoop.Feedback(events: setup)
246+
}
247+
248+
@available(*, deprecated, renamed:"init(_:)")
249+
public init(
250+
events: @escaping (
251+
_ state: SignalProducer<State, Never>,
252+
_ output: FeedbackEventConsumer<Event>
253+
) -> Disposable
254+
) {
255+
self.events = { events($0.producer, $1) }
256+
}
257+
}

Loop/Public/Loop.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,10 @@ public final class Loop<State, Event> {
3030
reducer: @escaping (inout State, Event) -> Void,
3131
feedbacks: [Loop<State, Event>.Feedback]
3232
) {
33-
box = RootLoopBox(
34-
initial: initial,
35-
reducer: reducer,
36-
feedbacks: feedbacks,
37-
startImmediately: true
38-
)
33+
let box = RootLoopBox(initial: initial, reducer: reducer)
34+
box.start(with: feedbacks)
35+
36+
self.box = box
3937
}
4038

4139
public func send(_ event: Event) {

0 commit comments

Comments
 (0)