Updated

Every form crashed when the keyboard appeared

A Swift 6 isolation check killed the app on iOS 27 the moment a software keyboard came up. The closure that trapped was never marked @MainActor. It inherited the isolation, and SwiftUI handed it to a renderer that does not run there.

Every form in the app died the moment a real software keyboard appeared over it. It was not one screen but all of them: the status composer, the template title field, the inline room creator, the description editor. It was deterministic on a cold launch, with four reproductions in a row and four identical stacks, and it happened in release configurations as well as debug.

It only happened on iOS 27. The same taps on 26.5 were fine, and that detail sent my first pass looking for a UIKit regression that did not exist.

The crash was not in any of those forms. It was in App.swift, inside a closure that carried no annotation at all.

The shape of the problem

The root of the app was an ordinary WindowGroup with a GeometryReader inside it, of the kind that sits near the top of a great many SwiftUI apps:

struct RoomAidApp: App {
    var body: some Scene {
        WindowGroup {
            GeometryReader { proxy in
                // ...the whole app
            }
        }
    }
}

There is no @MainActor anywhere in that code, and there does not need to be. RoomAidApp conforms to App, and App is @MainActor; every closure written inside body therefore inherits main-actor isolation automatically. You get it whether or not you asked for it, and in almost every case it is exactly what you wanted.

The content parameter of GeometryReader, however, is declared without any isolation; its type is (GeometryProxy) -> Content. The compiler therefore has to convert an isolated closure into a non-isolated one, and under Swift 6 that conversion is not free. It inserts a dynamic executor assertion at the closure’s entry, which is the runtime check described in SE-0423. Every time that closure runs, the process checks that it really is on the main actor and traps if it is not.

Until iOS 27 that check always passed, because the closure was always evaluated on the main thread.

Why the keyboard is what breaks it

Raising a keyboard invalidates the root geometry. The invalidation comes from the main thread, but in all four stacks the closure is re-evaluated by SwiftUI’s async renderer, on its own thread, through an attribute path that does not hop back to main for this attribute. The reading that fits is that the renderer already holds the view-graph lock when the invalidation lands, so it performs the re-evaluation itself.

The assertion fires, dispatch_assert_queue fails, and the runtime traps before the keyboard has finished animating.

This is also why it is rare. View.body never trips the same check, because SwiftUI declares that requirement @MainActor itself; there is no conversion, so there is no check. The trap exists only where an isolated closure is handed to a SwiftUI parameter that is declared without isolation, and a GeometryReader placed directly inside a Scene is one place an ordinary app does that.

Confirming it against the binary

The mechanism was confirmed against the compiled binary rather than left as an inference. The executor assertion in the built product carries the source file and line it would report on failure, and that line is the GeometryReader line itself.

That mattered, because an earlier attempt at this bug produced a fix that made the symptom go away without explaining it. This time the fix had to remove a specific check from a specific location, and the binary is where that can be seen.

The fix is to make the closure genuinely non-isolated

The tempting fix is a DispatchQueue.main.async or a Task hop around whatever triggers the invalidation, so that the re-evaluation happens to land on the main actor. That changes the timing and leaves the boundary in place. The closure is still isolated, the parameter is still non-isolated, and the check is still compiled in; any other path that evaluates the closure off the main actor trips it again, and now intermittently.

Instead, I made the closure genuinely non-isolated:

struct RoomAidApp: App {
    // A compile-time constant, so it never needed to be @State. Leaving it as
    // state would have kept the window content on the actor and defeated the
    // rest of this change.
    nonisolated static let isInternalBuild: Bool = { ... }()

    // A function reference, not a trailing closure. The WindowGroup closure
    // carried the same check, one frame out from the one that trapped.
    var body: some Scene {
        WindowGroup(makeContent: Self.rootWindowContent)
    }

    nonisolated static func rootWindowContent() -> some View {
        GeometryReader { proxy in
            RootWindowContent(screenProxy: proxy)
        }
    }
}

// Everything that genuinely needs the actor lives here instead, where SwiftUI
// declares body @MainActor itself. No conversion, so no check.
struct RootWindowContent: View {
    let screenProxy: GeometryProxy
    var body: some View { ... }
}

One detail did most of the work. A @State flag holding a compile-time build constant had to become a nonisolated static let. It was written once in init() and read only by body, so it never needed to be state, and reading it from the content function would have required main-actor isolation there, which would have kept the whole window content on the actor.

There is no dispatch hop, no Task.sleep, and no reordering of keyboard events. The rendered tree is unchanged.

The fix was confirmed against the binary as well as on the device. Across the whole region, meaning the scene body, the content function and the GeometryReader closure, there are now no references to swift_task_isCurrentExecutor. In the optimised build, the only references that remain sit inside an appearance closure that UIKit resolves on main. I also re-ran the pre-fix build on iOS 26.5 with a real keyboard, and it does not reproduce there, so the fix applies on every OS version rather than sitting behind a check for iOS 27.

There is no unit test

This failure needs SwiftUI’s async renderer running inside a real WindowGroup, which a unit test does not have. A test that asserts something about the source’s annotations restates the source without exercising the runtime check, so it would say nothing about whether the check is still compiled in.

The guard is a check over the built product instead. It runs nm and otool over the binary and looks for executor-assertion references in the region that should have none.

The other keyboard bug, which was not this one

The two surfaced together and looked related, so they are worth separating.

Tapping a field on a sheet that was still presenting hung the main thread until the process overflowed its stack. The stack it died with is a cycle:

  layout
    -> render display list
      -> remove view
        -> resignFirstResponder
          -> keyboard reloads input views
            -> layout          (and around again)

The field that SwiftUI was tearing down was the first responder, so every teardown re-entered the keyboard machinery, and the keyboard machinery caused the next teardown.

Focus itself was not the problem: a manual tap into the same field on the same build, once the sheet has settled, behaves normally. Focusing during presentation is the problem. I removed the auto-focus rather than delaying it, and changed the sibling screens with the same shape at the same time.

Where it stands

Swift 6 adds a runtime check at every boundary where an isolated closure meets a parameter declared without isolation, whether or not the isolation was ever written out. A GeometryReader, or anything else taking a non-isolated closure, placed directly inside an App’s body is one such boundary. Whether SwiftUI ever evaluates that closure off the main actor is up to SwiftUI, and on iOS 27 it did for this one. I do not know how many other attribute paths behave the same way; this is the one I have seen.

Hit this yourself, or think I have a detail wrong? I would like to hear about it: chase@chasemartin.dev.

← All posts