Having an @EnvironmentObject in the view causes Tasks to be executed on the main thread

I ran into an issue where my async functions caused the UI to freeze, even tho I was calling them in a Task, and tried many other ways of doing it (GCD, Task.detached and the combination of the both). After testing it I figured out which part causes this behaviour, and I think it's a bug in Swift/SwiftUI.

Bug description

I wanted to calculate something in the background every x seconds, and then update the view, by updating a @Binding/@EnvironmentObject value. For this I used a timer, and listened to it's changes, by subscribing to it in a .onReceive modifier. The action of this modifer was just a Task with the async function in it (await foo()). This works like expected, so even if the foo function pauses for seconds, the UI won't freeze BUT if I add one @EnvironmentObject to the view the UI will be unresponsive for the duration of the foo function.

Minimal, Reproducible example

This is just a button and a scroll view to see the animations. When you press the button with the EnvironmentObject present in the code the UI freezes and stops responding to the gestures, but just by removing that one line the UI works like it should, remaining responsive and changing properties.

import SwiftUI

class Config : ObservableObject{
    @Published var color : Color = .blue
}

struct ContentView: View {
    //Just by removing this, the UI freeze stops
    @EnvironmentObject var config : Config
    
    @State var c1 : Color = .blue
    
    var body: some View {
        ScrollView(showsIndicators: false) {
            VStack {
                HStack {
                    Button {
                        Task {
                            c1 = .red
                            await asyncWait()
                            c1 = .green
                        }
                    } label: {
                        Text("Task, async")
                    }
                    .foregroundColor(c1)
                }
                ForEach(0..<20) {x in
                    HStack {
                        Text("Placeholder \(x)")
                        Spacer()
                    }
                    .padding()
                    .border(.blue)
                }
            }
            .padding()
        }
    }
    
    func asyncWait() async{
        let continueTime: Date = Calendar.current.date(byAdding: .second, value: 2, to: Date())!
        while (Date() < continueTime) {}
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Disclaimer

I am fairly new to using concurrency to the level I need for this project, so I might be missing something, but I couldn't find anything related to the searchwords "Task" and "EnvironmentObject".

Your asyncWait function isn't actually asynchronous, so it blocks whatever thread it ends up running on, for 2 seconds. If the Task isn't running on the main actor, then your code won't block the UI, but it's still consuming CPU cycles on a different thread.

It's the same bug as when the Task does run on the main actor, but it's not obviously happening in this case.

If you want your Task to do something periodically, use the Task.sleep function, which is genuinely asynchronous. For example:

    Task {
         c1 = .red
         Task.sleep(for: .seconds(2))
         c1 = .green
     }
Accepted Answer

As far as the effect of @EnvironmentObject is concerned, it's a (little) known behavior from the original Swift Evolution proposal SE-0316:

"A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper."

Having an &#64;EnvironmentObject in the view causes Tasks to be executed on the main thread
 
 
Q