@MainActor + Combine inconsistent compiler error

38 views Asked by At

I have the following view model:

@MainActor
final class ProfileViewModel: ObservableObject {
  let authService: AuthService
  
  @Published var userHandle: String = "Unknown"
  
  nonisolated init(authService: AuthService, appState: AppState) {
    self.authService = authService
    appState.$userData
      .receive(on: DispatchQueue.main)
      .map {
        $0.user?.displayName ?? "Unknown"
      }
      .assign(to: &$userHandle)
  }
}

Which results in error: "Main actor-isolated property '$userHandle' cannot be used 'inout' from a non-isolated context", at the ".assign(to:)" line.

This is somewhat expected. As I understand it, Combine's dynamic thread switching doesn't satisfy compile-time guarantees of Swift's concurrency model.

Here's what's puzzling me. If I remove the @MainActor annotation from the class and apply it just to the userHandle property, like this:

final class ProfileViewModel: ObservableObject {
  let authService: AuthService
  
  @MainActor @Published var userHandle: String = "Unknown"
  
  nonisolated init(authService: AuthService, appState: AppState) {
    self.authService = authService
    appState.$userData
      .receive(on: DispatchQueue.main)
      .map {
        $0.user?.displayName ?? "Unknown"
      }
      .assign(to: &$userHandle)
  }
}

The code compiles without a problem.

Logically, this should raise the same error, since userHandle is Main actor-isolated in both cases. I was wondering if this is a compiler quirk or if there's a deeper semantic difference that I don't get.

1

There are 1 answers

0
CouchDeveloper On BEST ANSWER

Dispatch and Swift Concurrency is incompatible. During compile time, the compiler can only make a best effort to figure out whether code executes on the main thread when Dispatch is involved.

Anyway, I would suggest you refactor your class ProfileViewModel so that the class as a whole becomes MainActor isolated. Optionally, you may check whether you actually execute on the MainActor:


@MainActor
final class ProfileViewModel: ObservableObject {
    let authService: AuthService
    
    @Published var userHandle: String = "Unknown"
    
    var cancellable: AnyCancellable!
  
    init(authService: AuthService, appState: AppState) {
        self.authService = authService
        cancellable = appState.$userData
            .receive(on: DispatchQueue.main)
            .map { $0 }
            .sink(receiveValue: { string in
                MainActor.assumeIsolated {   // <== Optional runtime check
                    self.userHandle = string
                }
        })
  }
}

Note, that when you change the queue to some other non-main queue, the compiler will not issue any errors or warnings!

So you might want to add this check at runtime:

MainActor.assumeIsolated { ... }

when your code executes within a dispatch block.