1 minute read

Pol wisely reminds us to not forget to cancel long-running AsyncSequence observations, on X or BlueSky.:

// SearchViewModel will be leaked
@Observable @MainActor final class SearchViewModel {
    let (textStream, continuation) = AsyncStream.makeStream(of: String.self)

    init() {
        Task {
            for await text in textStream {
                print(text)
            }
        }
    }
}

// SearchViewModel will not be leaked
@Observable @MainActor final class SearchViewModel {
    let (textStream, continuation) = AsyncStream.makeStream(of: String.self)

    @ObservationIgnored private var task: Task<Void, Never>?

    init() {
        self.task = Task {
            for await text in textStream {
                print(text)
            }
        }
    }

    deinit {
        task?.cancel()
    }
}

Yi Qiang (@yqiang) on X replies, recommending using SwiftUI’s task modifier to kick off the subscription, allowing the framework to handle the cancellation for us. What would this look like in practice? We may be more familiar with using the task modifier to subscribe to AsyncSequences such as Notification without involving a model object:

struct ContentView: View {
    let model: Model

    var body: some View {
        ...
        .task {
            for await notification in NotificationCenter.default.notifications(named: .NSCalendarDayChanged) {
                print(notification)
            }
        }
    }
}

But we can equally well subscribe to our text stream:

@Observable @MainActor final class SearchViewModel {
    let (textStream, continuation) = AsyncStream.makeStream(of: String.self)
}

struct ContentView: View {
    let model: SearchViewModel

    var body: some View {
        ...
        .task {
            for await text in model.textStream {
                print(text)
            }
        }
    }
}

If we need to transform our stream, we might map the stream in our task modifier:

struct ContentView: View {
    let model: SearchViewModel

    var body: some View {
        ...
        .task {
            for await text in model.textStream.map({ $0.reversed() }) {
                print(text)
            }
        }
    }
}

We might wish to shift this transformation logic to our model. What we can do is move our for await loop, but not wrap it in a Task. Instead, we’ll put it in an asynchronous function:

@Observable @MainActor final class SearchViewModel {
    let (textStream, continuation) = AsyncStream.makeStream(of: String.self)

    func runTextStreamSubscription() async {
        for await text in textStream.map({ $0.reversed() }) {
            print(text)
        }
    }
}

struct ContentView: View {
    let model: SearchViewModel

    var body: some View {
        ...
        .task {
            await model.runTextStreamSubscription()
        }
    }
}

Now the logic is back in our model, but we can still lean on SwiftUI for an asynchronous context with which to work, and have automatic task cancellation.