Skip to content

Bridge generation loses MainActor isolation; #Preview bodies with @Observable state fail #175

@happitec-computer

Description

@happitec-computer

Hit three related compile failures when running previewsmcp snapshot against #Preview blocks that compile cleanly under swift build against the same package. In all three the error is in PreviewsMCP's generated bridge module (<module>_N.swift in /var/folders/.../T/previewsmcp/<uuid>/), not in the user's source. All three look like the same root cause: the bridge file generates @_cdecl C-bridge functions (createPreviewView, previewBodyKind) that are nonisolated, and the user's #Preview body inherits that nonisolated context — even though SwiftUI previews always execute on the main actor at runtime (UIHostingController, UIWindow, didFinishLaunching).

Failure 1 — @Previewable @State causes detect probe overload ambiguity

Repro

#Preview("Recording") {
    @Previewable @State var state: MyObservableState = {
        let s = MyObservableState()
        s.someBool = true
        return s
    }()

    ZStack {
        Color(.systemBackground).ignoresSafeArea()
        SomeView(state: state)
    }
}

Error

error: ambiguous use of 'detect'
return __PreviewBodyKindProbe.detect {
    @Previewable @State var state: MyObservableState = {
                                   ^- error: ambiguous use of 'detect'

The generated previewBodyKind() function wraps the user's #Preview body in __PreviewBodyKindProbe.detect { ... } to determine whether the body returns a View / UIView / UIViewController. The @Previewable @State var ... declaration at the top of the closure prevents the Swift compiler from inferring the closure's return type, so all three detect overloads remain candidates and resolution fails.

Workaround

Replace @Previewable @State with a regular let and put return on the view expression. (Loses Xcode Canvas interactivity, but PreviewsMCP only needs a static render.)

#Preview("Recording") {
    let state = MyObservableState()
    state.someBool = true
    return ZStack { ... }
}

Failure 2 — IIFE state-init pattern loses MainActor isolation in the bridge module

Repro

After applying Workaround 1 with the IIFE pattern still in place:

#Preview("Recording") {
    let state: MyObservableState = {
        let s = MyObservableState()
        s.someBool = true
        return s
    }()

    return ZStack { ... }
}

Error

error: main actor-isolated property 'someBool' can not be mutated from a nonisolated context
        s.someBool = true
          ^- error: main actor-isolated property 'someBool' can not be mutated from a nonisolated context

The state type is @MainActor-isolated (typically via @Observable). The IIFE creates a nested non-MainActor closure that can't mutate main-actor properties.

Workaround

Inline the mutations directly in the #Preview closure body (no IIFE):

#Preview("Recording") {
    let state = MyObservableState()
    state.someBool = true
    return ZStack { ... }
}

Failure 3 — Even inlined mutations are nonisolated in the generated bridge

Repro

After applying Workaround 2 (inline, no IIFE):

#Preview("Recording") {
    let state = MyObservableState()
    state.someBool = true
    return ZStack { ... }
}

Error

error: call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
error: main actor-isolated property 'someBool' can not be mutated from a nonisolated context

The generated createPreviewView is @_cdecl and lacks @MainActor:

@_cdecl("createPreviewView")
public func createPreviewView() -> UnsafeMutableRawPointer {
    let view = {
        return SwiftUI.AnyView(__PreviewBridge.wrap {
            let state = MyObservableState()    // nonisolated
            state.someBool = true              // nonisolated mutation
            return ZStack { ... }
        })
    }()
    let hostingController = UIHostingController(rootView: view)
    return Unmanaged.passRetained(hostingController).toOpaque()
}

The user's preview body lives inside __PreviewBridge.wrap { ... } inside a let view = { ... }() inside a nonisolated @_cdecl function. The body inherits nonisolated context all the way down.

Workaround

Wrap the entire preview body in MainActor.assumeIsolated — asserts the runtime-true fact that previews execute on the main actor:

#Preview("Recording") {
    return MainActor.assumeIsolated {
        let state = MyObservableState()
        state.someBool = true
        return ZStack { ... }
    }
}

This is awkward for the user; the cleaner fix is upstream.

Single suggested upstream fix that covers Failure 2 + Failure 3

Annotate the generated createPreviewView (and the closure passed to __PreviewBridge.wrap) as @MainActor. SwiftUI/UIKit view construction is required to be on the main actor anyway, and UIHostingController(rootView:) later in the same function already implicitly requires main-actor context. Making the wrapped user closure @MainActor () -> some View allows main-actor-isolated state types to be initialized and mutated naturally inside the user's #Preview body.

Concrete shape (suggested only):

@_cdecl("createPreviewView")
public func createPreviewView() -> UnsafeMutableRawPointer {
    // Preview is always presented via UIHostingController on the main actor.
    MainActor.assumeIsolated {
        let view = SwiftUI.AnyView(__PreviewBridge.wrap { @MainActor in
            // user's #Preview body
        })
        let hostingController = UIHostingController(rootView: view)
        return Unmanaged.passRetained(hostingController).toOpaque()
    }
}

Same shape for previewBodyKind — the __PreviewBodyKindProbe.detect closure should also be @MainActor.

Suggested upstream fix for Failure 1

Have __PreviewBodyKindProbe.detect use @_disfavoredOverload on the UIKit-typed overloads, OR restructure to take an autoclosure that doesn't need return-type inference at the call site. Either eliminates the ambiguity when leading variable declarations prevent the compiler from inferring the closure's return type.

Environment

  • macOS 26.x
  • Xcode-beta 26.4 RC (Swift 6.3)
  • PreviewsMCP main (commit current as of 2026-05-11)
  • iOS Simulator: iPhone 17 Pro / iOS 26.4
  • Source package: an iOS 17+ SPM library with #Preview blocks that each triggered one of the failure modes.

The user's library compiles cleanly with swift build against both the iOS-sim and macOS targets in all three cases — only PreviewsMCP's snapshot pipeline fails.

I have working workarounds for my own use case, so this isn't blocking — filing for future contributors who'll hit the same wall. Happy to provide a minimal-repro package if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions