swiftswiftuicombineswift-concurrency

@MainActor + Combine inconsistent compiler error


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.


Solution

  • 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.