linus_hologram
linus_hologram

Reputation: 1715

UI Update not triggered on Main Thread despite @MainActor annotation

I am annotating my function with @MainActor to ensure that it can be called from any async location safely to trigger a UI Update. Despite, I run into an error where, somehow, the UI Update seems to be attempted on a background thread, even though (to my understanding) the function is strictly bound to the `@MainActor´.

This is my code:

/// Dismisses the popup from its presenting view controller.
@MainActor public func dismiss() {
    presentingViewController?.dismiss(self)
}

It is called from inside an NSViewController which listens to a certain event using NotificationCenter, after which it initiates dismissal in the following objc func:

class MainWindowControllerVC: NSWindowController, NSWindowDelegate {
  override func windowDidLoad() {
      NotificationCenter.default.addObserver(self, selector: #selector(self.dismissVCastSharePopup), name: .NOTIF_DISMISS_POPUP, object: nil)
  }

  @objc private func dismissPopup() {
      // some other cleanup is happening here
      popup?.dismiss()
  }
}

I get the following error:

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'NSWindow drag regions should only be invalidated on the Main Thread!'

and the following warnings:

enter image description here

Can somebody please explain how this is possible? What am I misunderstanding here? If I wrap the same code into a DispatchQueue.main.async or even Task { @MainActor () -> Void in ... } I don't get this error. It is specifically tied to the annotation before the function.

Upvotes: 4

Views: 1503

Answers (1)

Rob
Rob

Reputation: 438417

tl;dr

When you isolate a function to the @MainActor, that is only relevant when you call this method from a Swift concurrency context. If you call it from outside the Swift concurrency system (such as from the NotificationCenter), the @MainActor qualifier has no effect.

So, either call this actor isolated function from Swift concurrency context, or rely on legacy main queue patterns.


There are a variety of ways to fix this. First, let me refactor your example into a MCVE:

import Cocoa
import os.log

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ViewController")

extension Notification.Name {
    static let demo = Notification.Name(rawValue: Bundle.main.bundleIdentifier! + ".demo")
}

class ViewController: NSViewController {
    deinit {
        NotificationCenter.default.removeObserver(self, name: .demo, object: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        addObserver()
        postFromBackground()
    }

    func addObserver() {
        logger.debug(#function)

        NotificationCenter.default.addObserver(self, selector: #selector(notificationHandler(_:)), name: .demo, object: nil)
    }

    func postFromBackground() {
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            logger.debug(#function)
            NotificationCenter.default.post(name: .demo, object: nil)
        }
    }

    @objc func notificationHandler(_ notification: Notification) {
        checkQueue()
    }

    @MainActor func checkQueue() {
        logger.debug(#function)
        dispatchPrecondition(condition: .onQueue(.main))
        logger.debug("OK")
    }
}

enter image description here

There are a few ways of solving this:

  1. My notificationHandler (dismissVCastSharePopup in your example) is not within a Swift concurrency context. But I can bridge into Swift concurrency by wrapping the the call in a Task {…}:

    @objc nonisolated func notificationHandler(_ notification: Notification) {
        Task { await checkQueue() }
    }
    

    Note, not only did I wrap the call in a Task {…}, but I also added a nonisolated qualifier to let the compiler know that this was being called from a nonisolated context. (Methinks it should infer that from the @objc qualifier, but it does not currently.)

  2. Alternatively, you can fall back to legacy patterns to make sure you're on the main thread, such as the block-based addObserver which allows you to specify the .main queue:

    private var observerToken: NSObjectProtocol?
    
    func addObserver() {
        observerToken = notificationCenter.addObserver(forName: .demo, object: nil, queue: .main) { [weak self] _ in
            self?.checkQueue()
        }
    
        …
    }
    

    Perhaps needless to say, even though we are no longer using the @objc selector method in this latter example, this closure will not be called in an actor isolated context, either, and therefore it will ignore the @MainActor attribute. The above pattern works solely because I followed legacy patterns and explicitly specified the .main queue to handle this observer.

Upvotes: 7

Related Questions