Skip to content

Invert subscription transforms in a reactive way (attempt No. 2)#420

Open
DivineDominion wants to merge 14 commits intomasterfrom
DivineDominion/invert-transform
Open

Invert subscription transforms in a reactive way (attempt No. 2)#420
DivineDominion wants to merge 14 commits intomasterfrom
DivineDominion/invert-transform

Conversation

@DivineDominion
Copy link
Copy Markdown
Contributor

Backstory: @mjarvis started this in #307 about 2 years ago. The way the ReSwift.Store is set up, his initial approach created a subscription right away. I suggested and tried to help with an approach where apps would have a PendingSubscription that only at the subscribe() call would be added to the Store.

But this didn't work out well in practice and both Malcolm and I have practically given up for the time being :)

With my recent adventures into the realm of making Middleware calls simpler, I experimented with another way to dispatch actions, and found it would fit ReSwift quite well.

This is a sample call:

store.subscription()
    .select { $0.substate.anotherSubstate }
    .skipRepeats()
    .subscribe()

This code heavily borrows concepts and implementation from RxSwift. I had quite some trouble wrapping my had around the concepts to be able to implement them properly.

A couple of notes regarding the reactive nature, aka how the inversion is done:

  • State updates are passed on as events in a reactive sequence, aka Observable<State>
  • State update events are produced via a BlockSubscriber: it forwards newState calls to a block which is set from the outside as a callback to pass events down the Observable stream using Observable.create
  • Observable.create sounds like you'd create the event stream itself, similar to creating arrays; but it ain't so. Instead, you define the connection/subscription from Observable to its Observer counterpart.
  • In fact, every transformation operator (select, filter, skipRepeats) adds another Observer on top that forwards events to the next one. The observable sequence is really a chain of observers passing on events, starting with the initial connection you specify in Observable.create.
  • The actual StoreSubscriberType in an app is not subscribed to the store anymore, just the BlockSubscriber is; the app's subscribers receive updates via the observable/observer chain.
  • BlockSubscriber is prepared in Observable.create to be subscribed to the store itself -- but the subscription is only executed when a connection happens. This is fun, and also weird, because I think about the creation first, like a "hot" sequence that just begins pumping out events, just but it is actually executed very late and, in some sense, lazily.

By using Observable and its observers, you can model transformations with operators without actually having a functional store subscription.

I left extensive comments in the code to make sense of the subleties of memory management. I still want to get rid of most parts of it. The dual of an Observable operator and its Sink, which is just an observer that applies the transformations and conditionally forwards the result to the next in the chain, will probably stick. But the Producer type, wrapping each step's objects in a cancelable objects with strongly references to its parts, maybe we can ditch that kind of stuff.

Important types

IncompleteSubscription

I went with @mjarvis's API, starting with Store.subscription() -> IncompleteSubscription<Store.State, Store.State>. The IncompleteSubscription<RootStoreState, Substate> tracks the root state type of the store so you cannot create an IncompleteSubscription in one store and stuff it into another, unless both use the same state type. .select operations modify the Substate associated type accordingly while RootStoreState stays the same.

IncompleteSubscription is the user-facing API. Observables and such are internal implementation details. Each operation replaces its underlying Observable<Substate>.

SubscriptionToken

Problem with hiding the actual subscription to the store in a hidden BlockSubscriber is that users cannot unsubscribe the real subscriber. They only know the object they passed in. (Let's call the one users own the "user-facing subscriber".)

I created SubscriptionToken to introduce a different state update mechanism in addition to notifiying all subscribers. You can unsubscribe each token individually, like regular subscribers. You can also still call Store.unsubscribe with the user-facing subscriber and all corresponding tokens will be removed. The tokens keep a strong reference to the observable connection, represented by the Disposable type; it receives the dispose() command when the token is deallocated. This breaks the connection and eventually frees the underlying BlockSubscriber as well.


Things I know are still missing:

  • add tests for Filter, if we need that operator at all, that is
  • test if it crashes expectedly or works properly from multiple threads
  • remove the v4 SubscriptionBox types etc. that introduced the "old" transformations, and revert this to the most basic ReSwift v2 Subscriptions. (We still need these for the BlockSubscriber to receive events, unless we remove the whole object-based subscription thing completely from the public API)
  • avoid double skipRepeats: if automaticallySkipRepeats is enabled, figure out if the latest operator is a skipRepeats as well (a simple boolean for IncompleteSubscription could do, or an type association to the SkipRepeats operator type itself)

I'd love to have your feedback on this! PRs to the branch of this PR are also very welcome if you want to play around with it! :)

hasher.combine(self.objectIdentifier)
}
#else
public var hashValue: Int {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy Hashing Violation: Prefer using the hash(into:) function instead of overriding hashValue (legacy_hashing)

self.observer.on(state)
}

// TODO: Use NSLock/atomic values for thread safety
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Todo Violation: TODOs should be resolved (Use NSLock/atomic values for t...). (todo)

_ observer: Observer,
cancel: Cancelable
) -> (sink: Disposable, subscription: Disposable)
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

internal class Producer<Substate>: Observable<Substate> {
internal override func subscribe<Observer: ObserverType>(_ observer: Observer)
-> Disposable where Observer.Substate == Substate
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

cancel: Cancelable)
-> (sink: Disposable, subscription: Disposable)
where Observer.Substate == Substate
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

cancel: Cancelable
) -> (sink: Disposable, subscription: Disposable)
where Observer.Substate == Substate
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

internal func composeSelect<SelectedSubstate>(
_ transform: @escaping (Substate) -> SelectedSubstate
) -> Observable<SelectedSubstate>
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

cancel: Cancelable
) -> (sink: Disposable, subscription: Disposable)
where Observer.Substate == Substate
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

public func subscribe<Subscriber: StoreSubscriber>(_ subscriber: Subscriber)
-> SubscriptionToken
where Subscriber.StoreSubscriberStateType == Substate
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

public func subscribe<Subscriber: StoreSubscriber>(_ subscriber: Subscriber)
-> SubscriptionToken
where Subscriber.StoreSubscriberStateType == Substate
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

middleware: [],
automaticallySkipsRepeats: true)

var receivedValue: TestStringAppState? = nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant Optional Initialization Violation: Initializing an optional variable with nil is redundant. (redundant_optional_initialization)


init<Subscriber: StoreSubscriber>(subscriber: Subscriber)
where Subscriber.StoreSubscriberStateType == Substate
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

subscriber: Subscriber
) -> SubscriptionToken
where Subscriber.StoreSubscriberStateType == State
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

subscriber: Subscriber
) -> SubscriptionToken
where Subscriber.StoreSubscriberStateType == Substate
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

subscriber: Subscriber
) -> SubscriptionToken
where Subscriber.StoreSubscriberStateType == Substate
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

subscriber: Subscriber
) -> SubscriptionToken
where Subscriber.StoreSubscriberStateType == Substate
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration. (opening_brace)

@DivineDominion
Copy link
Copy Markdown
Contributor Author

@mjarvis @danielmartinprieto Could you have a look at the approach some time?

@codecov-commenter
Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 76.98745% with 55 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.79%. Comparing base (b5b1fcc) to head (fd05601).
⚠️ Report is 39 commits behind head on master.

Files with missing lines Patch % Lines
ReSwift/CoreTypes/Inversion/Operators/Filter.swift 0.00% 19 Missing ⚠️
ReSwift/CoreTypes/Store.swift 78.57% 9 Missing ⚠️
...ft/CoreTypes/Inversion/Operators/SkipRepeats.swift 78.94% 8 Missing ⚠️
...eSwift/CoreTypes/Inversion/SubscriptionToken.swift 70.58% 5 Missing ⚠️
ReSwift/CoreTypes/Inversion/Observable.swift 71.42% 4 Missing ⚠️
ReSwift/CoreTypes/Inversion/Operators/Select.swift 83.33% 4 Missing ⚠️
ReSwift/CoreTypes/Inversion/Producer.swift 85.71% 4 Missing ⚠️
...t/CoreTypes/Inversion/IncompleteSubscription.swift 84.61% 2 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@             Coverage Diff             @@
##           master     #420       +/-   ##
===========================================
- Coverage   99.45%   86.79%   -12.67%     
===========================================
  Files           5       16       +11     
  Lines         185      424      +239     
===========================================
+ Hits          184      368      +184     
- Misses          1       56       +55     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants