CombineLatest in TCA

Hello, enjoying TCA architecture so far.

I have a question related with achieving similar goal with CombineLatest in TCA. Let's say when the splash screen appears, the screen must be shown to the user for at least 3 seconds, and while the splash screen is shown, the app throws an effect that contains an api call to get user information. So there can be 2 cases:

  1. When the getUser response comes within 3 seconds, we wait until the minimum 3 seconds to pass and move on.
  2. When the getUser response comes after 3 seconds, we move on as soon as we receives the response.

I know that this can be done by changing a state variable(e.g. threeSecondsPassed, gotUserResponse boolean) when 3 seconds has passed and the response is received respectively and whenever we receive a response we can check if both state variable are set to true and move on. However, I want to implement a cleaner solution like using combineLatest so that we can call an action to move on whenever we receive respective values from timer publisher, and getUser response publisher. I also know that the easiest way is to put a delay 3 seconds on receiving the response, but in this way we have to wait more than 3 seconds. Below is the pseudo code I initially thought, after checking Timer example in the case study:

//inside reducer

    case .onAppear: //called when splash view is shown
      let combinedPublisher = Publishers.CombineLatest(
        Effect.timer(id: TimerId(), every: 3, tolerance: .zero, on: environment.mainQueue).eraseToAnyPublisher(),
        
        environment
          .userClient
          .getUser()
          .subscribe(on: environment.backgroundQueue)
          .receive(on: environment.mainQueue)
          .catchToEffect()
          .eraseToAnyPublisher())

      return combined
          .map { (time, response) -> Result<User, Error> in
            return response
          }
        .eraseToEffect()
        .map(AppAction.userResponse) //userResponse is the action used to move on from splash view

However, this code doesn't work as we can't get the response from the combinedPublisher. What would be an ideal implementation to tackle this in TCA?

1 Like

What you'd like is an after operator that defers a publisher's output until after a specific date, rather than by a fixed delay. Then you could write this:

case .onAppear:
    let q = environment.mainQueue
    let when = q.now().advanced(by: .seconds(3))
    return environment.userClient
        .getUser()
        .subscribe(on: environment.backgroundQueue)
        .receive(on: q)
        .after(when, on: q) // <-- this is the new operator
        .catchToEffect()
        .map(AppAction.userResponse)

Here's a simple implementation of the after operator. Note that this is not thread-safe, so it's important to use after downstream of receive(on: q). Also, to understand this implementation, you need to understand that if you ask a scheduler to run a callback at a time in the past, the scheduler will run the callback as soon as possible.

struct AfterPublisher<Upstream: Publisher, Context: Scheduler>: Publisher {
    typealias Output = Upstream.Output
    typealias Failure = Upstream.Failure
    
    let upstream: Upstream
    let context: Context
    let when: Context.SchedulerTimeType
    
    func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
        let subject = PassthroughSubject<Output, Failure>()
        var isLive = true
        var ticket: AnyCancellable? = nil
        
        subject
            .handleEvents(receiveCancel: {
                isLive = false
                ticket?.cancel()
            })
            .subscribe(subscriber)
        
        // The subscriber might have cancelled the subscription synchronously.
        guard isLive else { return }
        
        ticket = upstream
            .sink(
                receiveCompletion: { completion in
                    context.schedule(after: when) {
                        if isLive {
                            subject.send(completion: completion)
                        }
                        ticket = nil
                    }
                },
                receiveValue: { value in
                    context.schedule(after: when) {
                        if isLive {
                            subject.send(value)
                        }
                    }
                })
    }
}

extension Publisher {
    func after<Context: Scheduler>(_ when: Context.SchedulerTimeType, on context: Context) -> AfterPublisher<Self, Context> {
        return AfterPublisher(upstream: self, context: context, when: when)
    }
}

@mayoff I see, this is a great way to solve this problem. Thank you!

I just found out that in my original pseudo code, simply adding ".print()" at the end of the combinedPublisher make everything work, but without .print() the code doesn't work. Does anyone know why this is happening?