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.