Skip to content

Add Swift 5.10+ onMainActor route builder#218

Open
louisdebaere wants to merge 6 commits intospotify:masterfrom
louisdebaere:louisd/mobius-onmainactor-routes
Open

Add Swift 5.10+ onMainActor route builder#218
louisdebaere wants to merge 6 commits intospotify:masterfrom
louisdebaere:louisd/mobius-onmainactor-routes

Conversation

@louisdebaere
Copy link
Copy Markdown
Contributor

@louisdebaere louisdebaere commented Mar 4, 2026

Why make this change

Mobius supports routing effects to the main thread via .on(queue: .main), but when the handler touches @MainActor-isolated state, Swift requires the call site to manually bridge isolation — typically with MainActor.assumeIsolated. This scatters a framework-level concern across consumer code and creates repetitive boilerplate that's easy to get wrong.

As projects adopt Swift Concurrency and @MainActor annotations spread (especially with Swift 6.2's defaultIsolation(MainActor.self)), this friction multiplies — every main-queue effect route needs the same wrapper.

This change introduces a Swift Concurrency-aware route that centralises the isolation bridge inside Mobius, keeping consumer code clean while preserving the existing dispatch semantics.

What will change

  • Add _PartialEffectRouter.onMainActor() returning a dedicated _MainActorPartialEffectRouter.
  • Add .to(...) overloads on _MainActorPartialEffectRouter accepting @MainActor @Sendable closures:
    • (EffectParameters) -> Void
    • () -> Void (when EffectParameters == Void)
  • Internally delegate to the existing .on(queue: .main) route path, then invoke closures via MainActor.assumeIsolated — no new dispatch mechanism, no Task, no ordering changes.
  • Gate the API and related tests behind #if compiler(>=5.10).

Before / After

Manual bridge at every call site:

.routeEffects(equalTo: .someEffect)
    .on(queue: .main)
    .to {
        MainActor.assumeIsolated {
            presenter.show()
        }
    }

.routeEffects(withParameters: extract)
    .on(queue: .main)
    .to { params in
        MainActor.assumeIsolated {
            handler.handle(params)
        }
    }

Bridge handled by the framework:

.routeEffects(equalTo: .someEffect)
    .onMainActor()
    .to {
        presenter.show()
    }

.routeEffects(withParameters: extract)
    .onMainActor()
    .to { params in
        handler.handle(params)
    }

Existing queue-based routes are completely unaffected:

.routeEffects(equalTo: .otherEffect)
    .on(queue: backgroundQueue)
    .to {
        processor.process()
    }

On compilers older than 5.10, .onMainActor() is unavailable by design. The existing .on(queue: .main).to { MainActor.assumeIsolated { … } } pattern remains the compatibility path.

Why this shape

Queue dispatch and actor isolation are separate concepts. .on(queue:) is a GCD concern; .onMainActor() is a Swift Concurrency concern. Giving them distinct API surfaces makes intent clear and avoids conflating the two models.

No new dispatch path. .onMainActor() delegates to the same .on(queue: .main) routing that already exists. Dispatch order, callback lifecycle, and Disposable semantics are identical — the only addition is the assumeIsolated bridge.

Centralised bridge. MainActor.assumeIsolated moves from potentially hundreds of call sites into one place in the framework, where it's structurally correct (the main queue is the MainActor executor) and documented.

Ready for Swift 6.2 consumer settings. When consumers enable defaultIsolation(MainActor.self) or Approachable Concurrency (NonisolatedNonsendingByDefault), their closures are implicitly @MainActor — the compiler picks the .onMainActor().to { } overload without any annotation at the call site. Without these overloads, those same consumers would face type mismatches or be forced to add nonisolated workarounds to use the existing queue-based API.

Alternatives considered

  • @MainActor overloads on the queue-agnostic builder. Simpler, but permits .on(queue: .global()).to(@MainActor ...) — shifts misconfiguration from compile time to a runtime trap.
  • Task { @MainActor in } instead of assumeIsolated. Safe by construction, but introduces an async hop on top of the queue dispatch, changing ordering guarantees and complicating callback.end() placement.
  • Typed main-queue token via @_disfavoredOverload. Compile-time enforcement, but relies on underscored API, breaks when aliasing DispatchQueue.main, and would strand _PartialEffectRouter extensions that return queue-bound builders.
  • Keep current call-site pattern only. No framework change, but leaves repetitive and easy-to-miss bridging in consumer code — increasingly painful as @MainActor adoption grows.

Given these trade-offs, .onMainActor().to { } is the minimal, explicit, and forwards-compatible addition.

@louisdebaere louisdebaere marked this pull request as ready for review March 4, 2026 12:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant