Subscriptions

Reducers aren't persistent. I know how to pause the program and print my app state, except for these loops.

I did come up with another implementation that completes subscriptions for elements that get removed, though it's quite ugly since I couldn't figure out how to use toOptional with it. It did make me wonder though, why are successive .some states skipped in toOptional? e.g. nil -> s1 -> s2 (skipped) -> nil. I ended up reimplementing that logic and it made me realise that I don't fully understand that part.

They actually not skipped in .toOptional :wink: . If you are experimenting in my "playground" code, then this "skip" actually occurs in:

extension Loop where State: Equatable {
    /// Starts observer for each new unique state and switches to latest publisher
    /// - Parameter observer: subscription func
    static func observe(with observer: @escaping (State, Env) -> AnyPublisher<Action, Never>) -> Loop {
        ...
    }
}

Because usually if state is the same, we don't want to resubscribe.

You're totally right about that, my mistake! Though I think the current implementation only works as expected when the state publisher is a CurrentValueSubject (or some other publisher that replays its last value to subscribers).

Right, noticed that too, but in the end this is non production code. Just quick sketch to propose another solution of subscriptions problem.
I'm new to Combine yet, used to RxSwift, and there is no shareReplay :smile:. So hopefully together we'll build right implementation.

Over the last week or so I've been playing with porting the Elm style subscriptions to SCA. The implementation isn't quite there yet (I've had to learn some of the finer points of Swift's type system but it's pretty close) and I thought it might be useful to show how the API would look so that we could contrast is to the Loop/Combine based approach.

The main difference vs the Loop approach is that you only declare the subscriptions you want and the management of subscribing/unsubscribing is taken care for you. It uses function composition rather than the more stateful pullback style but I'm not sure that that's an important distinction as both approaches could be expressed either way.

As usual some actual code probably paints the picture clearer. I've included an example with two pages, each with their own subscriptions.

import Combine

struct BluetoothDevice {
  let name : String
}

struct AppState {
  var currentPage : Page
  
  enum Action {
    case loginPageAction(LoginPage.Action)
    case bluetoothDevicesPageAction(BluetoothDevices.Action)
  }
  
  static func subs(_ appState : AppState) -> AppSub<Action>  {
    return pageSubs(appState.currentPage)
  }
  
  static func pageSubs(_ page : Page) -> AppSub<Action> {
    switch page {
    case .login(let loginPage):
      return PageOne.subs(loginPage).map("loginPage", .loginPageAction)
    case .bluetoothDevices(let bluetoothDevicesPage):
      return PageTwo.subs(bluetoothDevicesPage).map("bluetoothDevicesPage", .bluetoothDevicesPageAction)
    }
  }
}

enum Page {
  case login(LoginPage)
  case bluetoothDevices(BluetoothDevicesPage)
}

struct BluetoothDevicesPage {
  var bluetoothDevices : [String]
  
  enum Action {
    case deviceDiscovered(BluetoothDevice)
    case receivedDeviceData(BluetoothDevice, BluetoothData)
  }
  
  static func subs(_ loginPage : LoginPage) -> AppSub<Action> {
    return AppSub.merge([
      AppSub.deviceDiscovered("", Action.deviceDiscovered),
      AppSub.merge(loginPage.bluetoothDevices.map(deviceSubs))
    ])
  }
  
  static func deviceSubs(_ bluetoothDevice : BluetoothDevice) -> AppSub<Action> {
    return .deviceDataReceived({ data in
      .receivedDeviceData(bluetoothDevice, data)
    })
  }
}

struct LoginPage {
  var username : String
  var password : String
  
  enum Action {
    .usernameChanged(String)
    .passwordChanged(String)
    .loginClicked
    .currentTimeReceived(Date)
  }
  
  static func subs(_ loginPage : LoginPage) -> AppSub<Action> {
    return .currentTime(everySecs: 10, action: .currentTimeReceived)
  }
}

As I mentioned it's a very much Elm inspired take on the problem. I'm undecided as to how well it works with the grain of Swift and SCA but it at least seems a useful contrast to guide discussion.

@opsb Can you show what's the AppSub type?

As I understand, in Store.init you supply the State.subs function, which produces the new subscription for each state.

This is what I have so far

// Framework
public protocol Subscription {
    associatedtype Event
  typealias Lift<ParentEvent> = (Event) -> ParentEvent
  
  func map<ParentSubscription: Subscription>(_ lift:
    @escaping Lift<ParentSubscription.Event>) -> ParentSubscription
}

// App
enum AppSub<Event>: Subscription {
  case loadedArticles((Article, Bool) -> Event)
  case savedArticles(SavedArticlesConfig<Event>)
  case deletedArticles((Article) -> Event)
  case userLogins((User) -> Event)
  
  func map<ParentSubscription: Subscription>(_ lift: @escaping (Event) -> (ParentSubscription.Event)) -> ParentSubscription {
      switch self {
      case let .loadedArticles(cb):
          return AppSubscription<ParentSubscription.Event>.loadedArticles { article, isHot in lift(cb(article, isHot)) } as! ParentSubscription

      case let .savedArticles((optimistic: optimistic, pessimistic: pessimistic)):
        let mappedConfig : SavedArticlesConfig<ParentSubscription.Event> = (optimistic: { article in lift(optimistic(article)) }, pessimistic: { articleResult in lift(pessimistic(articleResult)) } )
        return AppSubscription<ParentSubscription.Event>.savedArticles(mappedConfig) as! ParentSubscription

      case let .deletedArticles(cb):
          return AppSubscription<ParentSubscription.Event>.deletedArticles { article in lift(cb(article)) } as! ParentSubscription
    
      case let .userLogins(cb):
        return AppSubscription<ParentSubscription.Event>.userLogins { user in lift(cb(user)) } as! ParentSubscription
    }
  }
}

I have been playing around with a wrapper struct which was used for identity and aggregation but I'm now looking to see if I can do that all through a single Subscription protocol and extension. I should have it fleshed out a bit more in the next couple of days but wanted to introduce it to the discussion now as I think it's a good to consider reactive subscriptions (e.g. Loop) vs a data oriented approach (Elm switched from reactive subscriptions to a data based approach several years ago at v0.17 elm-platform/0.17.md at master · elm-lang/elm-platform · GitHub).

Edit: Elm's type system is perhaps better aligned with the data based approach but I'm still learning more about what I can do with Swift's type system (the AppSub.map feels a bit gnarly at the moment, not sure if it can be improved upon ).

Edit: Apologies that was from another example I'm working will amend, for the example I posted.

Here's the implementation so far for the example I showed above. I've also managed to improve the generics for Subscription quite a bit in this version.

public protocol Subscription {
    associatedtype Event
  typealias Lift<ParentEvent> = (Event) -> ParentEvent
  
  func map<ParentEvent>(_ lift: @escaping Lift<ParentEvent>) -> Self where Self.Event == ParentEvent
}

enum AppSub<EventType>: Subscription {
  typealias Event = EventType
  
  case deviceDiscovered((BluetoothDevice) -> Event)
  case receivedDeviceData((BluetoothData) -> Event)
  
  func map<ParentEvent>(_ lift: @escaping Lift<ParentEvent>) -> AppSub<ParentEvent> {
    switch self {
    case .deviceDiscovered(let cb): return .deviceDiscovered({ device in lift(cb(device)) })
    case .receivedDeviceData(let cb): return .receivedDeviceData({ data in lift(cb(data)) })
    }
  }
}

I'll post a working version that pulls everything together ASAP as I appreciate this may be too bare bones to grok the idea at the moment.

Well it took me a fair while to figure out how to express the idea with Swift's type system but I got there in the end. You can see a runnable version of the exploration that I've been working on at SubsStudy - Swift Repl - Replit. It's heavily influenced by Elm's approach which can be seen in this page.

public struct BluetoothDevicesPage {
  var bluetoothDevices : [BluetoothDevice]
  
  public init() {
    self.bluetoothDevices = []
  }

  public enum Event {
    case deviceDiscovered(BluetoothDevice)
    case receivedDeviceData(BluetoothDevice, BluetoothData)
  }

  public static func subs(_ bluetoothDevicesPage : BluetoothDevicesPage) -> IdentifiedAppSub<Event> {
    return .batch([
      .sub("deviceDiscovered", .discoveredDevices(Event.deviceDiscovered)),
      .batch(bluetoothDevicesPage.bluetoothDevices.map(deviceSubs))
    ])
  }

  static func deviceSubs(_ bluetoothDevice : BluetoothDevice) -> IdentifiedAppSub<Event> {
    return .sub(bluetoothDevice.name, .receivedDeviceData({ data in
      Event.receivedDeviceData(bluetoothDevice, data)
    }))
  }
}

The subs callback is used to declare the subscriptions that should be active, including any configuration that might be needed. There's a SubscriptionsManager which then receives the aggregated Subs and takes care of starting/cancelling subscriptions as required (see SubsStudy - Swift Repl - Replit).

I couldn't find a way to identify subs purely based on configuration/callback actions alone and for this reason I've included a key with each sub e.g. .sub("deviceDiscovered", ...). Here it's a String but AnyHashable would probably be the most appropriate.

One of the advantages of this approach is that there's no dependency on Combine, though SCA has obviously hooked it's wagon to that horse anyway.

I'm curious what people's thoughts are, particularly as I haven't spent much time with Swift yet and don't have a broad awareness of it's idioms.

edit: Having explored this a bit more it seems that I really need higher kinded types to make this work the way I want (having to use AnySub causes the API to lose too much information before a Subscription is started, hence having start on the Sub rather than in a separate service class where I think it really belongs).