Avoiding zombie actions on optional reducers

A modal sheet is usually represented by an optional piece of state and an .optional() reducer. This means the View shouldn't fire any actions after it has been dismissed (i.e. after its corresponding state has been set to nil), else we hit assertion failures. So e.g. onDisappear is out of question, and for good reasons.

However, in some situations, its hard/impossible to avoid sending those actions. E.g. if you dismiss a screen while a TextField is focused, that TextField will still fire onEditingChanged as the keyboard gets dismissed.

I usually just resort to using forgivingOptional() which is my version of optional() reducer without the asserts. Is there a better solution?

There is something like your forgivingOptional in the works here - though I really like the name of your method.

I'd be really interested in a better solution for this, but I'm not sure that one really exists. As seen here - even the libraries official examples suffer from these issues. I've found them particularly prevalent around navigation in SwiftUI - ex. when you have a binding that triggers navigation, upon dismissing the view you navigated to the binding's send action is fired, and if it goes to an optional reducer you get a crash.

I also recently ran into the issue you mentioned with a textfield being focused. In my particular case my reducer is able to handle the focused state and change it to false before dismissal, but that doesn't scale well and I wouldn't expect all team members to be aware of that particular gotcha - so I'm sure it'll strike again.

This also happens for network calls, for example if your modal sheet was loading something when it was dismissed - you have to make sure that the parent store tells the child one to cancel it's in flight effect. This is actually a case where I think the crash does make sense - it's user initiated and potentially wasting resources by not cancelling it (depending on when exactly you cancel it), so it would be good to know that you've forgotten to cancel it. Though even this could benefit from a more scalable way to handle cancellation/teardown, as it becomes complex if you have a deeply nested tree with an optional at the root - it potentially has to trigger cancellation of network calls in it's children's children.

Ideally the library would be forgiving when the actions are due to limitations/implementation decisions of SwiftUI that are out of the control of the developer, while unforgiving/less forgiving of things they can control. I'm not sure such a distinction is possible. Maybe rather than a forgivingOptional() method, we need a forgivingSend()? So UI bindings could use forgivingSend() while leaving the optional reducer unforgiving.

2 Likes

Thanks for the link, glad to see a solution is in the works. Glad you like my naming but tbh I kinda hate it :grinning:.

A few random points regarding the forgivingSend() idea:

  • Imho this problem can happen on any optional reducer and even as a result of wrong ordering of reducers (one reducer nils-out the state and a subsequent optional reducer hits assertionFailure). So its not like we can just shut off the send eagerly (would still have to process everything, only if it came from a forgiving send, assertionFailures would be turned off)
  • Note that this Action can also come from Effects, not View. This is actually the case with my keyboard observer which is an Effect on NotificationCenter. Im not quite sure what the equivalent of forgiving send would be there.

I also agree that we should default to strict behavior to catch uncancelled requests and similar. I consider the "dont forget to cancel everything from parent" aspect of this to be one of the weakpoints of TCA. The ergonomy is poor since you often want the implementation details of the cleanup to be a component's private business, so you end up with teardown(state:, environment:) helpers that pollute the global namespace. I wonder if people did something better. I was considering some higher-order-reducers to manage an optional component's lifecycle, but ultimately this keeps bringing me back to subscriptions, which have been discussed elsewhere on this forum.

Sorry it looks like I mixed up two things. The keyboard observer point is non-sense, you can always cancel your effects in time. So forgivingSend still sounds promising.