A Small SwiftUI Warning and a Long Journey to Understand It
I was migrating to a newer version of The Composable Architecture, which meant there was a list of deprecations to clean up. One of the things on that list was adding InferSendableFromCaptures as an upcoming Swift feature flag across all our package targets.
SE-0418 is genuinely interesting. It makes method references participate more accurately in sendability checking when used as values. One of those proposals that feels obviously correct once you read it.
I added it while preparing for Swift 6, hoping to catch issues before the TCA 2.0 work. I was ready to fix code. I was not ready for another journey of concurrency discovery. The kind that challenges things I already knew, just to make sure I really knew them.
This is one of those posts I write because writing is thinking. Even at the risk of being wrong. That is actually better, because I will learn more. So don’t take this as a tutorial or the truth. Use critical thinking as you follow along.
A bunch of warnings appeared, all of the same shape, all of them in places where I was passing a SwiftUI view’s initialiser as a function reference to a navigation modifier.
.navigationLinkDestination(
item: $store.scope(...),
destination: ChildView.init(store:) // ⚠️
)
The warning said something like “call to main actor-isolated initializer init(store:) in a synchronous nonisolated context”.
To understand what was happening I had to properly internalise what SE-0418 changes about function references. The warning appears because ChildView.init(store:) is not just “a function that makes a view”. ChildView conforms to View, and that makes the initialiser main actor-isolated. But the destination: parameter expects @escaping (StoreOf, with no @MainActor. When I pass the initialiser directly, I am asking the compiler to use a main actor-isolated initialiser as a plain synchronous callback. In Swift 5 mode it lets me do that, but warns me that the isolation does not fit the context I am putting it in.
That was the first model in my head, at least. It sounded convincing enough…
Once I saw it that way, the fix felt obvious: switch to a closure.
.navigationLinkDestination(
item: $store.scope(...),
destination: { ChildView(store: $0) } // ✅
)
No warning.
We’re done, right? I applied it everywhere, but as I was doing it my inquisitive mind kept asking. But why?
Why did the closure work? They both call the same initialiser. They both end up constructing the same @MainActor view. Why is one fine and the other not?
What InferSendableFromCaptures Actually Does
SE-0418, the proposal behind InferSendableFromCaptures, adds type inference specifically to function and method references when used as values.
Before enabling the feature flag, ChildView.init(store:) used as a value slipped through as a non-sendable function value. After enabling it, the reference carries more of what the declaration means. The initialiser is main actor-isolated, so the function reference is treated as main actor-isolated too.
That is the right thing to do. The initialiser is @MainActor. The reference should say so. But the parameter I am passing it to is typed as @escaping (StoreOf, with no @MainActor annotation. So, in my first reading, the compiler was complaining because the function reference now brought main actor isolation with it, and the parameter did not ask for that.
Closure literals do not work that way. A closure literal has no pre-existing function reference that has to be squeezed into the parameter type. The compiler creates it fresh to satisfy the parameter type, @escaping (StoreOf, and then type-checks the body of the closure where it is written. The warning never fires because the compiler is no longer trying to use the isolated initialiser itself as the callback value.
That seemed to explain the difference between the two syntaxes. But it left me with a deeper question.
The Question That Bothered Me
If the closure literal is created to match type @escaping (StoreOf, with no @MainActor annotation, then how can it safely call ChildView(store:) inside? That initialiser is @MainActor. I am inside a closure that is not annotated @MainActor. How is that not a problem?
In Swift 6 strict mode, calling a @MainActor function from a nonisolated context is an error. Not a warning. An error.
So I switched to .swiftLanguageMode(.v6), expecting to see it break.
It did not.
The closure compiled cleanly. No warning, no error. And that is when I realised I had been thinking about the wrong thing.
The Closure Is Not Nonisolated
The closure does not have @MainActor in its type, but that does not mean it runs in a nonisolated context. Of course it doesn’t, and I knew that. But I let the compiler warning pull my attention to the wrong place. It is incredible how quickly a weak mental model can crumble when you trust the first explanation that seems to fit.
I defined it inside body. And body is @MainActor. A closure literal inherits the actor context of the scope it is written in, not just the type annotation of the parameter it satisfies. So the closure is effectively @MainActor at the point where it runs, even if that annotation is not visible in the parameter type.
Calling ChildView(store:) inside that closure is calling a @MainActor function from a @MainActor context. That is always safe. The compiler knows this. So there is no error in Swift 6. And there was no warning before. Everything was consistent enough to fool me.
At that point I thought I had it. The closure works because it inherits the main actor context. The function reference warns because it does not get to use that same context in the same way.
Nice little explanation.
Or is it?
The Explanation Cracks
Later I explained the whole thing to a coworker. And while explaining it, I felt that familiar discomfort. The words were coming out, but something felt off.
Because if the closure works because it inherits the main actor context from the view, then why would that not also apply to the method reference?
So I made the example smaller and more annoying.
struct ParentView: View {
var body: some View {
Text("parent")
}
func makeView() -> some View {
doSomethingOnTheMainActor()
let inferredReference = ChildView.init(store:)
let typedReference: (StoreOf<Int>) -> ChildView = ChildView.init(store:)
let mainActorReference: @MainActor (StoreOf<Int>) -> ChildView = ChildView.init(store:)
let mainActorClosure: @MainActor (StoreOf<Int>) -> ChildView = { @MainActor in
ChildView(store: $0)
}
_ = (inferredReference, typedReference, mainActorReference, mainActorClosure)
return ChildView(store: StoreOf())
}
}
@MainActor
func doSomethingOnTheMainActor() {}
The important line is not even the function reference. It is this one:
doSomethingOnTheMainActor()
doSomethingOnTheMainActor() is @MainActor. I am calling it without await. And it compiles.
That means makeView() is already being treated as main actor-isolated, even though I did not write @MainActor on it. So my previous explanation was incomplete. The surrounding method is not some random nonisolated context. The compiler is already treating it as main actor-isolated.
Then the results got weirder.
In Swift 5 mode with InferSendableFromCaptures enabled, the inferred function reference warned. The explicitly typed plain function reference warned. Even the explicitly typed @MainActor function reference warned.
But the @MainActor closure did not.
And that is where it started to smell. mainActorReference and mainActorClosure have the exact same type:
@MainActor (StoreOf<Int>) -> ChildView
But one warned and the other did not.
That is the part that broke the simple explanation. If the problem were simply “you are in a nonisolated context”, then doSomethingOnTheMainActor() should have required await. It did not. And if the problem were simply “this is main actor-isolated but the parameter does not accept that”, then the closure should have failed too once I realised it was main actor-isolated. In my mind, if one failed, the other should fail. If one worked, the other should work.
Then I added @MainActor explicitly to makeView().
All warnings disappeared.
Then I removed that explicit annotation again, but switched the package to Swift 6 language mode.
All warnings disappeared too.
That was the real clue.
The Transitional Bit
So I no longer think the interesting lesson is “closures inherit actor context and method references do not”. That is too clean. It is also not quite true.
What I think is happening is that this warning is a Swift 5 migration-mode artifact. Enabling InferSendableFromCaptures in Swift 5 mode makes method references participate in stricter concurrency checking, but that checking does not seem to line up perfectly with the actor isolation inference that Swift 6 applies. The surrounding SwiftUI method is already main actor-isolated. Swift 6 understands the whole thing and accepts it. Swift 5 with the upcoming feature flag sees enough to warn, but not enough to recognise what Swift 6 recognises.
The closure workaround still makes sense. It avoids the warning and expresses the code in a way the Swift 5 checker is happy with. But it is not proof that the original function reference was unsafe. It is more like a shape of code that avoids a transitional diagnostic.
And this is the subtle part that I think is easy to miss.
Upcoming feature flags, at least the ones related to the complex concurrency story, are not always the same experience as fully moving to the language mode where that feature belongs. They are there to surface future issues early, but they can expose interactions with the old mode that do not quite represent the final model.
What I Took Away
The fix itself was small. One line changed per call site.
But understanding why it worked took a real detour through several parts of the concurrency model: how SE-0418 adds inference to function references, how closure literals behave differently, how actor context is inherited rather than just declared, and how Swift 5 migration checking can differ from Swift 6 even when an upcoming feature flag is involved.
The funny part is that I already knew most of those pieces. I knew closures inherit actor context. I knew SwiftUI views are main actor-isolated. I knew upcoming feature flags are part of a migration story. And yet I still managed to convince myself of an explanation that fit what I was seeing.
That is the dangerous part. When the first explanation is plausible, it is very easy to stop there. I could have changed all the call sites, written down “closures inherit actor context”, and walked away with a mental model that was close enough to feel right, but wrong enough to mislead me the next time.
Instead, the discomfort kept bothering me. The result did not feel fully justified. So I kept pushing. I made the example smaller, compared the function reference and closure with the exact same type, tried explicit @MainActor, tried Swift 6 mode, and only then did the model start to hold together.
The warning was not telling me my code was broken. At first I thought it was telling me I had a straightforward isolation mismatch. That was close. But not quite. The deeper lesson was that the compiler mode matters. In Swift 5 mode with this upcoming feature enabled, I was looking at a transitional warning. In Swift 6 mode, the same code made sense to the compiler. Adding @MainActor explicitly also made it make sense.
That is the kind of thing you understand properly only when you stop accepting the fix and start asking why it works. Not just until you find an explanation, but until you find one that survives the next question.