§ 01 · 2026 · 05 · 16 · Concurrency
The Swift 6 @MainActor listener-conformance error, and a seam that sidesteps it
If you've migrated a Router-Interactor-Builder codebase to Swift 6, you've probably met this one: "Main actor-isolated property 'listener' cannot be used to satisfy nonisolated protocol requirement." It's not a bug in your code. It's a seam that was never assigned an isolation domain — and there's a way to design it so the error can't occur.
The Router-Interactor-Builder pattern has a listener seam: a view forwards user events "up" to the unit that owns the decision. The classic shape is a presentable protocol the view conforms to, plus a back-reference to a listener it calls. It compiled cleanly for years. Under Swift 6 strict concurrency it often doesn't:
Main actor-isolated property 'listener' cannot be used
to satisfy nonisolated protocol requirement
That's the diagnostic exactly as filed in Uber's actively-maintained RIBs-iOS issue #43 (Swift 6.0). Later Swift versions reworded it — 6.3 reports it as a #ConformanceIsolation error, "conformance … crosses into main actor-isolated code" with a "cannot satisfy nonisolated requirement" note — but it is the same conformance problem, and it shows up in nearly every codebase that adopts a RIB-shaped listener seam and then turns on the Swift 6 language mode. It is not a niche edge case.
Why the seam triggers it
The error is the compiler asking a question the original design never answered: which isolation domain does the listener live in?
The view layer is @MainActor — that part was never in doubt; UIKit and SwiftUI require it. So when a view controller conforms to a presentable protocol, the view controller's members are main-actor-isolated. But the protocol requirement it's satisfying — var listener: PresentableListener? { get set }, declared on a protocol with no isolation annotation — is nonisolated. Swift 6 will not let a main-actor-isolated property be the witness for a nonisolated requirement, because a caller holding only the protocol type could touch listener from any executor.
// No isolation on the protocol → requirement is nonisolated.
protocol HomePresentable: AnyObject {
var listener: HomePresentableListener? { get set }
}
@MainActor // views must be @MainActor
final class HomeViewController: UIViewController, HomePresentable {
weak var listener: HomePresentableListener?
// ❌ Main actor-isolated property 'listener' cannot be used
// to satisfy nonisolated protocol requirement
}
You can paper over it — annotate the witness nonisolated, sprinkle @preconcurrency, or hop through a Task { @MainActor in … }. Each one trades a compiler error for a runtime question mark, and the seam still has no declared home. The real fix is to give it one.
The fix everyone converges on
The view-facing seam should be @MainActor. That's not a workaround — it's the truth. The presenter, the view controller, and the listener handle that the view holds all live where the view lives. RIBs-iOS is landing exactly this conclusion: issue #43's research and the follow-on PR move the framework's view-side types onto @MainActor. It's the right call, and it's a non-trivial one to retrofit into a framework whose API has to stay source-compatible for everyone already shipping on it.
napkin had the advantage of starting after Swift 6, so it could make that decision once, at the bottom, and never special-case it again.
napkin's version: annotate the base protocol, once
napkin's base Presentable protocol is a single annotated line:
@MainActor
public protocol Presentable: AnyObject {}
Every feature's presentable seam refines that protocol — so it inherits the isolation. There is no per-feature, per-property annotation to remember and no place to forget one:
// Refines Presentable → the whole protocol, including any
// `var listener` requirement, is @MainActor by inheritance.
protocol HomePresentable: Presentable {
func presentUser(_ user: User) async
}
@MainActor
final class HomeViewController: UIViewController, HomePresentable {
weak var listener: HomePresentableListener?
// ✅ requirement is @MainActor; witness is @MainActor. No mismatch.
}
The requirement and the witness are now in the same isolation domain by construction. The error isn't suppressed — it's unreachable. You cannot write the failing shape in napkin without first un-annotating a framework protocol.
Two listeners, two directions, both sound
"Listener" means two different seams in a RIB tree, and it's worth being precise about which one the error is about.
- View → interactor — the presentable listener. The view holds it and forwards taps. This is the one that hit the Swift 6 error, and it's sound in napkin because the whole
Presentableprotocol is@MainActor. - Interactor → parent — the child-to-parent listener. A child interactor signals an event up to whichever parent built it.
The second one never had the problem, for a different structural reason. It isn't a protocol requirement a view witnesses across an isolation boundary — it's a plain stored property on the interactor actor, and the listener protocol itself is Sendable with async methods so the call legitimately crosses into the parent's actor:
protocol ProfileListener: AnyObject, Sendable {
func profileDidFinish() async
}
final actor ProfileInteractor: PresentableInteractable {
weak var listener: ProfileListener? // actor-isolated property
func didTapDone() async {
await listener?.profileDidFinish() // explicit crossing
}
}
Neither direction can produce the nonisolated-witness error: one because the seam is uniformly @MainActor, the other because it isn't a cross-isolation protocol witness at all. The full pattern reference is in the docs.
Where napkin agrees with RIBs-iOS, and where it doesn't
napkin and RIBs-iOS reach the same conclusion about the view seam: it belongs on the main actor. The deliberate divergence is one ring deeper. RIBs-iOS's modernization unifies the framework on @MainActor including the interactor; napkin keeps the interactor as a final actor off the main actor, so business logic is never scheduled behind UIKit's executor.
That's the Clean Architecture dependency rule expressed in Swift's isolation system: UI (outer) may depend on business rules (inner), never the reverse. Putting the interactor on @MainActor would compile, but it would structurally bind every decision your app makes to the UI thread — the inversion the architecture exists to prevent. The cost of keeping them separate is an await at each crossing; the benefit is that the boundary is real and the compiler enforces it. The reasoning is laid out in full here.
Both frameworks ship the same pattern. RIBs is alive, well, and actively working through Swift 6 adoption on a public API it has to keep stable. napkin is the same Router-Interactor-Builder shape with the isolation decisions made up front — Clean Architecture on a napkin.
The takeaway
If you're hitting "cannot be used to satisfy nonisolated protocol requirement" on your own listener seam, the durable fix isn't a nonisolated annotation on the witness — it's deciding, once, that the view-facing protocol is @MainActor, and annotating it at the lowest point so every refinement inherits it. Suppress the error and the seam still has no home; annotate the protocol and the question is answered everywhere at once.
Want to see it end to end? The Rib House example is a few hundred lines, and the tutorial walks the seam.