Guidance for nonisolated with SwiftUI extensions

While working through enabling Swift 6 Language Mode, I noticed that SwiftUI's own struct initializers and extensions ( Text, ScrollView, .frame(), etc. ) are marked as nonisolated, allowing hierarchies to be built outside Main Actor isolation.

When declaring our own views and extensions, should we be striving to use nonisolated as well?

If so, what are the best practices to mitigate the concurrency errors that result in our implementations?

If not, are we expected to simply isolate any view building to the MainActor when non-Apple SwiftUI views are involved?

Thanks!

3 Likes

SwiftUI-specifically is not covered, but the general idea of non-isolated initializers makes an appearance in the migration guide here and might at least be a good starting point.

https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/commonproblems#Non-Isolated-Initialization

I'm very curious to hear from others, though. I haven't put in a lot of thought, but have also wondered about this.

3 Likes

As you may know, SwiftUI used to only annotate body with @MainActor, the switch to the whole protocol inferring isolation is new with iOS 18.

I believe that the motivation for the change was to make it easier to avoid having to mark member variables and functions called inside of body as MainActor in the strict language mode, but this is just speculation on my part.

But it does suggest one possible explanation: the authors forgot, or saw no need, to specifically mark the inits as @MainActor before, and this change is simply to preserve the old behavior, in case there is someone who depends on it.

There is definitely some interesting stuff going on in SwiftUI related to concurrency; for example, State is Sendable, and the only MainActor isolated property wrapper I'm aware of, StateObject, is likely on the road to deprecation.

2 Likes

Hi, I have some difficulty in understanding the example in the migration doc:

@MainActor
class WindowStyler {
    private var viewStyler = ViewStyler()
    private var primaryStyleName: String


    nonisolated init(name: String) {
        self.primaryStyleName = name
        // type is fully-initialized here
    }
}

Since primaryStyleName is isolated to MainActor, I wonder why the nonisolated init() method is able to set it?

I find a similar example in SE-0316 (global actors), but it's not clear if the nonisolated method accesses MainActor isolated state in the type.

    @MainActor                                                                                                        
    class IconViewController: NSViewController {                                                                      
      @objc private dynamic var icons: [[String: Any]] = [] // implicitly @MainActor                                  
                                                                                                                      
      var url: URL? // implicitly @MainActor                                                                          
                                                                                                                      
      private func updateIcons(_ iconArray: [[String: Any]]) { // implicitly @MainActor                               
        icons = iconArray                                                                                             
                                                                                                                      
        // Notify interested view controllers that the content has been obtained.                                     
        // ...                                                                                                        
      }                                                                                                               
                                                                                                                      
      nonisolated private func gatherContents(url: URL) -> [[String: Any]] {                                          
        // ...                                                                                                        
      }                                                                                                               
    } 
1 Like

If the property couldn't be set by the init, how else would it be initialised?

I venture to say that this is what's happening: During the initialisation, the property is treated as nonisolated.

It can just use the default behavior, in which it's MainActor isolated. Of course, that may cause issue in some scenarios (see the example in migration guide), but that's another story.

I agree init() is special and it's unlikely to have race condition when an instance's states are initialized by init(). If so, however, I expect this behavior should be documented in SE proposal. But I don't think it was.

Also, the migration doc has the following text, which in my understanding implies only init() not accessing any global actor state can be declared nonisolated:

Globally-isolated types sometimes don’t actually need to reference any global actor state in their initializers. By making the init method nonisolated , it is free to be called from any isolation domain.

2 Likes

Initialization is quite complex. It has envolved over a number of proposals, and sometimes it’s hard to track down exactly which one covers existing behavior.

But, in this case, I think the idea is the compiler can reason that because this instance hasn't yet been created, its MainActor properties cannot possibly cross actor boundaries at this point. There are many examples of the compiler relaxing constraints because it is useful, and it can prove that no races are possible.

1 Like

Lots of great insights here! Let's look at another example, ScrollView's initializer:

This initializer is marked as nonisolated, but it accepts a ViewBuilder for Content, which is not Sendable. How do you suppose SwiftUI is pleasing the compiler inside the initializer's implementation?

If I naively assume that their initializer looks like:

nonisolated
init(
    _ axes: Axis.Set = .vertical,
    @ViewBuilder content: () -> Content
) {
 self.axes = axes
 self.content = content()
}

Then the axes assignment would be fine, given that Axis.Set conforms to Sendable.
But Content has no Sendable guarantees or generic constraints on sendability, so we would expect a isolation error here.

My first thought was it might relate to region based isolation, but it was very far stretched. Then I realized a simple explanation: nonisolated synchrnous func inherits isolation domain from its caller! Take ScrollView.init(_:content:) as an example, it's typically called in a view's body, which is MainActor isolated, so it's MainActor isolated too. That's why it works.

If this understanding is correct (I believe so), it means declaring ScrollView.init(_:content:) as nonisolated is meaningless in typical scenarios. It's only useful in a hypothetical scenario where one instantiates a ScrollView in another isolation domain and transfers it to code running in MainActor (though I doubt if there is such a scenario in practice).

@mattie I think this explanation applies to the example in migration doc too.

PS: This is another example why I think nonisolated is a source for confusion in Swift concurrency.

2 Likes