How can The Composable Architecture really become composable?

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 :slight_smile:

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)
    }

:sunny:

I think this solution covers these considerations:
:white_check_mark: TCA should always assume views and reducers are reused
:white_check_mark: TCA should always assume reducers needs to stop ongoing effects
:white_check_mark: Every reducer/view/store should be able to cancel its ongoing effects without affecting other "instances"
:white_check_mark: 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 :blush:

4 Likes