How to reference SwiftUI view from async Task with strict concurrency checks?

The use case is pretty simple. I have a SwiftUI view from which I dispatch a task for asynchronous background work during the click on a button. When an error is thrown, I want to catch it in the task and propagate the error message back to the SwiftUI view.

Steps To Reproduce

  1. Create a new SwiftUI app project for macOS in Xcode.
  2. Replace the template ContentView with the code below.
  3. Set "Strict Concurrency Checking" to "Complete" in the build settings of the project.

ContentView.swift

struct ContentView: View {
    @State private var errorMessage: String?

    var body: some View {
        VStack {
            Button(action: doSomething) {
                Text("Do Something")
            }
        }
        .padding()
    }

    func doSomething() {
        Task {
            errorMessage = "Oops!"
        }
    }
}

Xcode reports a warning for the line in which I assign a value to errorMessage:

Capture of 'self' with non-sendable type 'ContentView' in a @Sendable closure

I understand that. But how can I resolve that problem? How can I dispatch a code block back in the original concurrency domain of the SwiftUI view here while retaining the new concurrency patterns? Obviously, the view itself is not supposed to be @Sendable. On the other hand, I cannot reference a single thing in all of my ideas without encountering this warning.

You could mark your view with "@MainActor" to get rid of that warning.
If you do that you'll get another warning in a different place:

Button(action: doSomething) {
// 🔶 Warning: Converting function value of type '@MainActor () -> ()' to '() -> Void' loses global actor 'MainActor'

That warning you could sweep under the carpet:

extension Button {
    init(_ action: @MainActor @escaping () -> Void, @ViewBuilder label: () -> Label) {
        self.init(action: action, label: label)
        // 🔶 Warning: Converting function value of type '@MainActor () -> Void' to '() -> Void' loses global actor 'MainActor'
    }
}
Button(doSomething) { // ✅
    Text("Do Something")
}

I'm not sure this is the best way.

This made me think and apparently I figured it out. Instead of passing the function directly, I introduce a closure with a call. Not very elegant but free of issues. :blush:

@MainActor
struct ContentView: View {
    @State private var errorMessage: String?

    var body: some View {
        VStack {
            Button {
                doSomething()
            } label: {
                Text("Do Something")
            }

            Text("Error message: \(errorMessage ?? "")")
        }
        .padding()
    }

    func doSomething() {
        Task {
            errorMessage = "Oops!"
        }
    }
}

Yeah, not terrible, but it does seem like boilerplate you shouldn't have to do. And I suspect it's merely taking advantage of a gap (i.e. a bug) in the checks, since calling doSomething from your closure logically makes your closure @MainActor too.

SwiftUI is unfortunately full of these Strict Concurrency Checking problems - I think because SwiftUI simply predates things like @MainActor, otherwise it would have used them from the start. I wonder if adding @MainActor is ABI-compatible? It's not source-compatible, strictly speaking, but then neither are the Strict checks (which are intended to become on by default in Swift 6; source breakage is coming… I'm not sure what the library migration story is there, if the necessary changes are binary-incompatible…).

1 Like

Perhaps. FWIW there's an explicit marker we can put on closures to make them @MainActor:

{ @MainActor in mainActorOnlyCall() }

If we do it in this example the warning is back.

    Button { @MainActor in
        doSomething() // Converting function value of type '@MainActor () -> ()' to '() -> Void' loses global actor 'MainActor'
    } label: {
        Text("Do Something")
    }

What should happen in the following example? Should it be treated as an error?

    {
        mainActorOnlyCall()
        someOtherActorOnlyCall()
    }

Seems like…? Unless I'm missing something, it seems like a clear case of erroneously conflating isolation domains.

1 Like