IfLetStore and Effect cancellation on view disappear

I am trying to implement a view that is updated by a long-time-running effect - a timer. The view should be presented inside another view which decides if the view with a timer should be presented or not, based on its state.

This may sound a bit complex or hard to understand, so I made an example project and I am sharing it on GitHub: https://github.com/darrarski/tca-ifletstore-effect-cancellation-demo

When the view with timer appears, the timer is started by sending an action to the store. When the view with timer disappears I would like the timer to stop, so I am sending another action to the store, that should cancel the effect.

The problem is that the view with timer sits inside IfLetStore view and when it disappears, its state is already nil which triggers the following error:

Fatal error: "DetailAction.stopTimer" was received by an optional reducer when its state was "nil". This can happen for a few reasons:

* The optional reducer was combined with or run from another reducer that set "DetailState" to "nil" before the optional reducer ran. Combine or run optional reducers before reducers that can set their state to "nil". This ensures that optional reducers can handle their actions while their state is still non-"nil".

* An active effect emitted this action while state was "nil". Make sure that effects for this optional reducer are canceled when optional state is set to "nil".

* This action was sent to the store while state was "nil". Make sure that actions for this reducer can only be sent to a view store when state is non-"nil". In SwiftUI applications, use "IfLetStore".

I am not sure how should we handle such cases, and how the long-running-effects should be canceled.

Thanks in advance for any hints or ideas!

Hey @darrarski, thanks for the detailed report!

It may seem like that assertFailure is a little strict, but it is indeed what we want, and without it you would be silently hiding a bug rather than loudly complaining of a programmer error :grimacing:

The bug is that when the dismiss button is tapped you immediately nil out the detail state, which causes the detail view to go away, which then causes the onDisappear to be invoked, which finally sends the .stopTimer action. However, that action will never be delivered to your reducer because the state has already been nil'd out, and so there is no longer anything to reducer on. That essentially means that without the assertionFailure the timer would be quietly running forever, sending actions to the store, and you wouldn't know unless you happen to have .debug() on the reducer to see everything that was coming in.

So, the assertion really is trying to catch a potential bug, but that's not to say that this is the best way to handle the situation. The optional higher-order reducer is good for optionally showing/hiding views based on state, but if you need to hook into the lifecycle methods from within that view, in particular onDisappear, then you are always going to run into that assertion.

So this has motivated us to find a version of optional that is lifecycle aware. We have sketched a version of the higher-order reducer here, along with a case study:

We want to spend a little more time with it and see if there is any more polish we can apply, but the results are promising so far. Want to give it a spin and see if it helps you?

1 Like

Oh, and I should also say, you can fix this without resorting to the experiment code I linked to and just stick with optional. You just have to make sure to cancel the timer before you nil out the detail state, and stop using onDisappear. The .lifecycle reducer extension I pasted above just helps automate that for you and helps keep all of detail's logic in one place.

1 Like

Wow, thank you for your effort and quick replay! I really appreciate it :heart:

I will try to fix the issue in my demo project as you advised.

That approach of encapsulating the lifecycle is quite interesting. In my case I'm not that interested on the lifecycle itself but just a way to close a screen, but still I can take some tricks from it.

I think the important thing here is understanding how critical the order is. I think got so accustomed to the combined(with other: Reducer) -> Reducer variant that I introduced that it was tricky to accept that I should reverse the order.

@mbrandonw your solution works perfectly! This is exactly what I was looking for. Once again, thank you so much for all the effort you make with @stephencelis, helping others to build apps with composable architecture.

I pushed changes on a separate branch in demo repository. You can find it here: https://github.com/darrarski/tca-ifletstore-effect-cancellation-demo/tree/solution-lifecycle

Have a nice day!

@darrarski that's awesome to hear, and thanks for being a beta tester of the method! :laughing:

We'll polish up the docs and case study for it in the coming days and push out a release soon!

@mbrandonw I noticed a strange behavior when canceling effects from onDisappear. The underneath publisher is canceled, but not released from the memory. While I didn't notice such issues before, when canceling effects from regular actions, I can't be sure yet if the issue is not in my implementation (may be not related to TCA). I will post more info once I investigate the problem.

@darrarski ok interesting. yeah please let us know if you have anymore details.

Are you seeing that the memory isn't released in the memory graph debugger?

@mbrandonw I didn't have much time to work on this, but I've managed to prepare an example on a separate branch of my demo project: https://github.com/darrarski/tca-ifletstore-effect-cancellation-demo/tree/custom-timer

It uses your lifecycle-reducer concept, but instead to Effect.timer I am using a custom timer publisher I made especially for this example. I have added some ugly prints as well :slight_smile:

To reproduce the issue with publishers not being deallocated after cancel:

  1. Run the app in a simulator
  2. Tap "Present Detail" and "Dismiss Detail" couple of times
  3. Notice following output on the console:
^^^ CustomTimerPublisher.init (1)
^^^ CustomTimerSubscription.init (1)
^^^ CustomTimerSubscription.cancel
^^^ CustomTimerSubscription.deinit (0)

^^^ CustomTimerPublisher.init (2)
^^^ CustomTimerSubscription.init (1)
^^^ CustomTimerSubscription.cancel
^^^ CustomTimerSubscription.deinit (0)

^^^ CustomTimerPublisher.init (3)
^^^ CustomTimerSubscription.init (1)
^^^ CustomTimerSubscription.cancel
^^^ CustomTimerSubscription.deinit (0)

The number in parentheses is a count of instances in memory. It looks like the subscription is correctly canceled and deinitialized, but not the publisher.

I've also tried to use Xcode's memory debugger to investigate what holds the publisher instances in the memory, but unfortunately, I didn't found any valuable hints beside I confirmed the publishers were not deinitialized.

Perhaps there is something wrong with my custom publisher implementation, but I am not sure about it. It's rather simple.

Here is a link to all changes in the code I made for this example: https://github.com/darrarski/tca-ifletstore-effect-cancellation-demo/compare/solution-lifecycle...custom-timer?expand=1

UPDATE

It looks like wrapping the custom publisher in Deferred before converting it to Effect solves the issue with retained publishers. I pushed the changes here: https://github.com/darrarski/tca-ifletstore-effect-cancellation-demo/commit/7cdc0c6ffb0cae15d37b15be2bb25da8d0455fc2

I am still not sure what actually caused the problem. It could be connected with how Effect cancellables are stored, or perhaps my custom publisher implementation is missing something.

UPDATE 2

While my custom publishers are no longer retained (which seems to be ok), now the Deferred publishers are still in the memory (wrapped in PublisherBox), so it looks like the issue is not completely solved :frowning_face:

Once again, thanks for the support! :heart:

I started to investigate the issue with retained publishers on a demo project from this thread. Eventually I decided to create a new thread and a new demo project especially for this issue I'm experiencing. I think it's not related to the lifecycle of a component, but somehow it only appears when using IfLetStore. More details in the following thread:

Terms of Service

Privacy Policy

Cookie Policy