Problem
Whilst migrating to Swift 6 language mode, I recently encountered an issue:
I needed to reference main actor isolated code inside of a non-isolated closure.
Because withObservationTracking
is from the Foundation
library, I couldn't simplify modify the function defintion.
withObservationTracking {
ViewModel() // Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
} apply: {
}
If we look at the definition of withObservationTracking
, we can see apply
is nonisolated.
// nonisolated closure
_ apply: () -> T
Isolating a nonisolated closure
As it turns out, we can actually isolate this "nonisolated closure" by ensuring its created from an isolated actor.
@MainActor // add this
func work() {
withObservationTracking {
// No more error
// Runs on the MainActor
ViewModel()
} apply: {
}
}
It's wild to me that we can get away with this. It seems to work even if the closure is @escaping
.
For example, this works too:
@MainActor
final class ViewModel {
var count: Int = 0
}
@MainActor
func testCode() {
perform {
let viewModel = ViewModel()
print(viewModel.count)
}
}
nonisolated func perform(_ work: @escaping () -> Void) {
// ...
}
From my naive understanding, it seems Swift allows this as work
doesn't cross an isolation boundary.
As soon as we update perform
to the following:
// Add `@Sendable`
nonisolated func perform(_ work: @escaping @Sendable () -> Void) {
// ...
}
We run into this issue:
@MainActor
func work() {
perform {
let viewModel = ViewModel()
print(viewModel.count) // Main actor-isolated property 'count' can not be referenced from a Sendable closure
}
}
Thats where we have to ensure our main actor code always runs on the main thread, perhaps by using a DispatchQueue.main.async
:
@MainActor
func work() {
perform {
// No errors
DispatchQueue.main.async {
let viewModel = ViewModel()
print(viewModel.count)
}
}
}
Summary
If we think about it — if you're already on the main actor, all non-isolated code is technically isolated to the main actor during that period.
The Swift compiler is smart enough to figure this out.
Its only when your nonisolated code might cross an isolation domain that the Swift compiler gives us a warning.