Types that must be MainActor, but cannot be MainActor due to protocol conformance

In the following simplified code, we're getting stuck with MainActor being both forbidden and required. We have a ViewModifier that observes a ObservableObject. Therefore it must be MainActor (and in fact, becomes MainActor automatically due to the ObservedObject wrapper).

However, it is used within a ButtonStyle. ButtonStyle requires a non-isololated makeBody method. But if it's nonisolated, then it can't access the ViewModifier. Under "complete" concurrency, the following either generates "Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context" or ""Main actor-isolated instance method 'makeBody(configuration:)' cannot be used to satisfy nonisolated protocol requirement."

My belief is that this is a SwiftUI bug. If View.body is MainActor, ViewModifier.body(content:) should be as well. But until SwiftUI changes, or in the case that it never changes, is there any way out of this corner?

// A global "configuration" class that things can observe to turn on and off the debugOverlay
// It is MainActor to ensure @Published only emits on the main actors, and to permit the create of `.global`
@MainActor
final class Config: ObservableObject {
    static let global: Config = .init()
    @Published public var debugOverlayEnabled = false
}

// An overlay that displays whenever the global debugOverlayEnabled changes
// Using @ObservedObject quietly makes the MainActor
private struct DebugComponentOverlay: ViewModifier {
    @ObservedObject var globalConfiguration = Config.global

    func body(content: Content) -> some View {
        content.overlay {
            if globalConfiguration.debugOverlayEnabled {
                Color.green.opacity(0.25)
            }
        }
    }
}

// Now we're stuck. This needs to be MainActor in order to use DebugComponentOverlay,
// but cannot be MainActor and still conform to `ButtonStyle`
// "Main actor-isolated instance method 'makeBody(configuration:)' cannot be used to satisfy nonisolated protocol requirement
struct LinkButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        Button("Text") {}
            .modifier(DebugComponentOverlay())
    }
}
7 Likes

It looks like Swift will let you get away with the following:

@MainActor
struct LinkButtonStyle: ButtonStyle {
    private let overlay = DebugComponentOverlay()
    nonisolated func makeBody(configuration: Configuration) -> some View {
        Button("Text") {}
            .modifier(overlay)
    }
}

although that doesn't seem totally valid to me (since I feel like a nonisolated shouldn't be allowed to access a MainActor instance variable).

1 Like

@Helge_Hess1 on Mastodon just answered this for me, and it works as expected.

struct LinkButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        MainActor.assumeIsolated {  // <------
            Button("Text") {}
                .modifier(DebugComponentOverlay())
        }
    }
}
1 Like

Have you checked this with "concurrency=complete?" I don't think it works.

Yes, this is with -strict-concurrency\=complete. But I prefer the assumeIsolated anyway.

Note that -strict-concurrency=complete will warn about things in Swift 5.10 that it didn't warn about in earlier versions. So your experience may vary depending on which version of Swift you are using.

1 Like

Yeah, the recommended way to do this is using MainActor.assumeIsolated. The goal of SE-0423: Dynamic actor isolation enforcement from non-strict-concurrency contexts is to streamline this pattern using @preconcurrency protocol conformances since this pattern is so pervasive and necessary to facilitate an incremental migration for an entire ecosystem of Swift code.

10 Likes

Thanks Holly for this explicit advice. You're one of a kind giving such explicit and practical advice and this is greatly appreciated. Really, I mean it, you're precious and unique so far.

I hope we won't have to use those magic incantations for too long. I mean - some people will totally understand it, and some other people will just blindly copy and paste until the compiler stops complaining. I can't decide if this is the signs of a language that has forgotten its progressive disclosure precept, or the sign of Apple SDKs that sadly lag behind the language (for unfortunate and probably very stupid reasons because everybody is :flushed::roll_eyes::thinking::face_with_peeking_eye::confounded::grimacing::interrobang:). I hope the problem is in the SDK and that everybody will synchronize very soon because we're not in a pleasing situation. After all there are plenty of teams at Apple that can beta-test your releases. I wonder why we have to bite the dust.

9 Likes

I agree that "add nonisolated and then wrap in assumeIsolated" is a very ugly incantation. What speaks to me about SE-0423 and @preconcurrency is that it replaces that kind of "chant" with a clearly spelled explanation of what's happening, which is also very easy to remove once it's no longer necessary. I do expect people to add it too much, but the compiler even gives you a warning when you don't need it, helping avoid it becoming a vestigial cut-and-paste chant. (I don't know whose idea that was, but they clearly care about us very much and learned from our previous experiences. :hugs:)

I do expect to see a similar pattern to implicitly unwrapped optionals. People used ! types a lot, in bizarre ways that were completely unnecessary. (I still don't get why people thought marking every property in a simple struct with ! was of any value, but I saw it constantly.) But, as the underlying libraries got audited, IUOs have steadily faded from our code, and I don't see our new developers copying them any more.

It took years, yes. And I think we should expect at least as long for @preconcurrency to fade. But that's ok. It was fine, and it'll be fine again. Choices like SE-0423 are a big deal IMO because they explicitly put a box around the issue, give it a name, and make it easy to remove like we removed all the ! marks before. We should continue to be on the lookout for other situations where people have to write weird code just to work around a current problem, and apply similar solutions.

I'm sure I'm not the first to say it, but I think Swift 5.10 is a Swift 3 moment. Before Swift 3, Swift promised a lot, but struggled to really deliver. It was seriously debatable whether it was worth the trouble. With Swift 3, IMO, Swift became a real language. There were still lots of issues that took years to smooth out, but its promise was finally real. Swift Concurrency up to this point has been similar. Lots of promise, but in practice I've spent much more time fighting with it than getting real value out of it. I think 5.10, as a preview of Swift 6, changes that.

The fact that converting to concurrency=complete has, in just a few hours, exposed real threading bugs in our code, as well as finding a long-existing mistake in UIKit, tells me we are on the right track now.

I just need a built-in pattern for serial transactions and a semaphore, please. :smile:

14 Likes

Sorry, I don’t have something productive to add, but…

This, this, and a thousand times more: THIS! There are so many places now that fall apart if you peek behind the curtains. It’s sad to see such a high potential language being treated like that.

1 Like

https://developer.apple.com/forums/thread/749566

I encountered a similar situation building a custom SwiftUI.DynamicProperty from a 6.0 toolchain. I'm using the SE-0423 workaround for now. I guess I have to wait for the next version of SwiftUI to find out if this workaround is no longer needed after the WWDC release.