Introducing swift-dependencies, a dependency injection library inspired by SwiftUI's "environment"

I’ve just come up against a sharp edge in using swift-dependencies which others may find useful to know, if it’s not obvious (it wasn’t to me at first). It’s not a direct issue with swift-dependencies, but something that happens naturally if you’re using the library and using something along the lines of Point-Free’s 'tree of view models' setup for an app.

Given a dependency with a long-running publisher endpoint:

public struct Authentication {
    // [...]
    public var updates: AnyPublisher<User?, Never>
}

Naturally you’re going to subscribe to that in your view models to keep any user-related state current:

@MainActor public final class ProfileModel: ObservableObject {
    @Dependency(\.authentication) var authentication
    @Published var isSignedIn = false
    
    public init() {
        Task {
            for await user in authentication.updates.values {
                isSignedIn = user != nil
            }       
        }
    }

    deinit {
        print("I’m called when the profile screen is dismissed, right?")
    }
}

In fact, the profile model never gets released when its parent model changes destination it because there’s a retain cycle in the task here, which implicitly captures self through authentication and isSignedIn.

You can do one of two things. You can start adding capture groups to all your tasks:

Task { [weak self, authentication] in
    for await user in authentication.updates.values {
        self?.isSignedIn = user != nil
    }       
}

Personally I don’t like this because it’s so easy to miss the self?. on a property inside the task.

The other option which makes a lot more sense is to use structured concurrency by adding SwiftUI’s task {} modifier to take care of the lifetime of the task. You can then capture self as much as you like and it won’t matter because there’s a definite point at runtime where the task will be cancelled and all your self references go away safely before deinit.

// ProfileModel.swift
// [...]
func trackUserUpdates() async {
    for await user in authentication.updates.values {
        isSignedIn = user != nil
    }
}

// ProfileScreen.swift
// [...]
var body: some View {
    Text("Profile Screen")
        .task { await model.trackUserUpdates() }
}

This is probably super-obvious to many users, but it took me a while to notice the problem. The one thing I’m not sure about is that not starting the task automatically in init means that during testing creating an instance of the model doesn’t immediately set up all the expected state (notably if the underlying update publisher is using a CurrentValueSubject or equivalent so that your model would usually get an immediate initial value). You have to start and stop that task manually rather than it just coming along for the ride as the model is inited and released. Maybe that’s ok? Maybe having long-running task publishers in dependencies is an anti-pattern? But what’s the alternative for external things that change like auth states and data stores? I’d love to hear from anyone else working with this setup.