swiftswiftuiasync-awaittask

Why isn't a Task automatically cancelled when a View disappears?


According to Apple's documentation, a task{} will automatically cancel when a view disappears.

SwiftUI will automatically cancel the task at some point after the view disappears before the action completes.

Why, in this scenario, is the Task in the 'loadData' method not cancelled when the ChildView disappears? (When navigating back to the 'ParentView')

struct ChildView: View {
    
    @State private var data: [Double] = []
    
    private func loadData() async {
        // Why isn't this Task automatically cancelled when the ChildView disappears?
        // The Task is still executing in the background.
        Task(priority: .background) {
            // Simulating heavy data processing.
            self.data = (1...3_000_000).map { _ in Double.random(in: -10...30) }
            print("Task ended")
        }
    }
    
    var body: some View {
        Text("This is a child view")
            .task { await loadData() }
    }
}

struct ParentView: View {
    var body: some View {
        NavigationStack {
            NavigationLink(destination: { ChildView() }) {
                Text("Show child view")
            }
        }
    }
}

The task in 'loadData' continues executing even when the ChildView disappears. It also executes multiple times when the ChildView is initialized multiple times in a row.

This could potentially lead to memory leaks, especially when using a class with a @Published 'data' property instead of the @State property in the ChildView. What is the correct implementation for using a Task() in such a scenario?


Solution

  • SwiftUI does cancel the task created implicitly by the task view modifier, but that task doesn't do the "heavy data processing". That task only creates a subtask to run loadData. This subtask completes almost immediately.

    This is because all loadData does is it creates a top level task by using Task { ... } and does nothing else. By the time your view disappears, the loadData task would have already completed. The top level task, however, does all the "heavy data processing", and because it is a top-level task (i.e. not a child of the loadData task), it doesn't get cancelled when loadData is cancelled.

    You should not create a top level task here. Put the heavy data processing directly in loadData.

    Also, task cancellation is cooperative - loadData should also check Task.isCancelled and stop what it's doing.

    private func loadData() async {
        for _ in 1...3_000_000 {
            if Task.isCancelled { // for example
                break
            }
            self.data.append(Double.random(in: -10..<30))
        }
        print("Task ended")
    }