Skip to content

v0.1.1 — Navigator safe from any actor context

Choose a tag to compare

@Sajjon Sajjon released this 03 May 14:32
· 49 commits to main since this release
6d549c2

Patch release fixing a runtime trap in `Navigator.next(_:)` when called from a Combine sink that resumed off the main actor.

What was broken

`Navigator.next(_:)` was `@MainActor`-isolated, requiring the caller to already be on main. This trapped at runtime (`_swift_task_checkIsolatedSwift` → `EXC_BREAKPOINT`) when called from a Combine pipeline that crossed schedulers — typical pattern: a view-model awaits an async use case that bridges back via `Future { promise in Task { promise(.success(value)) } }` (e.g. Zesame's `CombineWrapper`). The `Future`'s `Task` doesn't preserve caller actor isolation, so the downstream `.sink { navigator.next(.x) }` ran off-main and trapped.

The fix

`Navigator.next(_:)` is now `nonisolated` and bridges to main internally:

  • On main: fast-path via `MainActor.assumeIsolated` — no `Task` overhead.
  • Off main: dispatches the subject `send` via `Task { @mainactor in … }`, so coordinator subscribers always receive on the main actor regardless of which scheduler the upstream pipeline used.

This means consumer pipelines no longer need a `.receive(on: DispatchQueue.main)` between an actor-crossing operator (`Future`-bridging, custom schedulers, etc.) and the navigator call — `Navigator` is robust at the integration boundary.

Type changes

  • `Navigator` is `@unchecked Sendable` (Combine's `PassthroughSubject` doesn't yet conform to `Sendable`). The unchecked claim is bounded: every `send` goes through `next(_:)` which guarantees main-actor isolation, the `@MainActor` `navigation` accessor covers reads, and Combine's own subscription bookkeeping is documented thread-safe. `@unchecked` will be removed when `PassthroughSubject` gains native `Sendable`.
  • `Navigator.next(_:)` is now under `extension Navigator where NavigationStep: Sendable` (the step is captured across the actor hop). All known consumers (Zhip, the `SignUpDemo` example) already declare `Sendable` on their step enums.

Migration from 0.1.0

Trivial for the typical case — if your `NavigationStep` enum is a simple value type (no payloads or only `Sendable` payloads), it's already `Sendable` implicitly. Otherwise add `: Sendable`:

```swift
public enum SignUpStep: Sendable {
case signedUp(User) // User must also be Sendable
case userPressedHaveAccount
}
```

Consumer pipelines that previously needed `.receive(on: DispatchQueue.main)` between an actor-crossing publisher and the navigator call can drop it — `next(_:)` handles the hop now.

Credits

Diagnosed end-to-end by tracing `EXC_BREAKPOINT` across the Zesame → NanoViewController → Zhip integration boundary. The fix lives in NanoViewController because it's the layer that owns navigation-pulse delivery semantics; making `next(_:)` schedule-tolerant means every consumer pipeline is robust.

Full changelog: v0.1.0...v0.1.1