I have not heard anything from anyone. I think the best solution going forward is alternative 1
SomeView().onDisappear {
viewStore.cancelAll()
}
That is something the authors of TCA would have to approve. I still have hope
Workaround: CancellationBag
In the meantime while waiting for TCA authors I have created a workaround I am happy with – which I also think wold be a good pull request for TCA. You'll find the PR here!
A CancellationBag
is a class that has the ability to cancel a collection of cancellation id's
public final class CancellationBag {
...
public static func bag(id: AnyHashable) -> CancellationBag
public func cancelAll() // cancel all cancellables
public func cancel(id: AnyHashable) // cancel one cancellable
}
Your environment can by default create the bag like this
struct AvatarEnvironment {
struct BagID: Hashable { }
var bag = CancellationBag.bag(id: BagID())
}
Our aim is to reuse and have multiple instances of AvatarEnvironment at the same time so the owner of the AvatarView may override the environment
struct DetailEnvironment {
struct BagID: Hashable { }
var bag = CancellationBag.bag(id: BagID())
var me: AvatarEnvironment {
struct BagID: Hashable { }
return AvatarEnvironment(bag: CancellationBag.bag(id: BagID()).asChild(of: bag))
}
var peer: AvatarEnvironment {
struct BagID: Hashable { }
return AvatarEnvironment(bag: .bag(id: BagID()))
}
}
This seems like a mouthfull so it can be simplified into
struct DetailEnvironment {
var bag = CancellationBag.autoId()
var me: AvatarEnvironment { .init(bag: .autoId(childOf: bag)) }
var peer: AvatarEnvironment { .init(bag: .autoId(childOf: bag)) }
}
by using a simple extension
extension CancellationBag {
public static func autoId(childOf parent: CancellationBag? = nil, file: StaticString = #file, line: UInt = #line, column: UInt = #column) -> CancellationBag
}
This extension creates a unique AnyHashable
based on file, line number and column number of caller.
Further, now that we have a bag on our environment, we can now start using this CancellationBag.
First place is in the side effects we initiate.
case .onAppear:
struct Cancellation: Hashable {}
return Publishers.Timer(
every: 1,
tolerance: .zero,
scheduler: DispatchQueue.main,
options: nil
)
.autoconnect()
.catchToEffect()
- .cancellable(id: .Cancellation())
+ .cancellable(id: .Cancellation(), bag: env.bag)
.map { _ in DetailAction.timerTicked }
Secondly, to the optional reducer we can now specify a CancellationBag
detailReducer.optional(cancellationBag: { $0.bag }).pullback(
state: \.detail,
action: /AppAction.detail,
environment: { $0.detail }
),
The optional reducer will then automatically cancel all ongoing side effects if the state is nil
extension Reducer {
public func optional(cancellationBag: @escaping (Environment) -> CancellationBag) -> Reducer<
State?, Action, Environment
> {
.init { state, action, environment in
guard state != nil else {
cancellationBag(environment).cancelAll()
return .none
}
return self.run(&state!, action, environment)
}
}
}
So basically when you try to do this...
MyView().onDisappear {
viewStore.send(.onDisappear)
}
...then it will just workâ„¢! Because if the reducer is in an optional and nilled out state tree the cancellation is happening for all cancellation bags down the chain whenever an action is sent. And further if the reducers state is not nilled out it will be able to cancel by itself by doing
case .onDisappear:
return .cancelAll(bag: env.bag)
}
I think this solution covers these considerations:
TCA should always assume views and reducers are reused
TCA should always assume reducers needs to stop ongoing effects
Every reducer/view/store should be able to cancel its ongoing effects without affecting other "instances"
There should be a minimum of pitfalls and gotchas to learn
Summary about CancellationBag
In essence all you need to know is that you can specify a cancellation bag to the optional reducer
reducer.optional(cancellationBag: { $0.bag })
you can scope your cancellable effects
effect.cancellable(id: Cancellation(), bag: env.bag)
and you can can create an overridable cancellation bag for your environment like this
struct AvatarEnvironment {
var bag = CancellationBag.autoId()
}
A working demo of the solution can be found here:
Let me know your thoughts