Skip to content

Explore: Output<Publishers> wrapper replaces cancellables-on-VM#11

Draft
Sajjon wants to merge 4 commits into
mainfrom
transform_result_builder
Draft

Explore: Output<Publishers> wrapper replaces cancellables-on-VM#11
Sajjon wants to merge 4 commits into
mainfrom
transform_result_builder

Conversation

@Sajjon
Copy link
Copy Markdown
Owner

@Sajjon Sajjon commented May 15, 2026

Draft / exploration — not for merge as-is. Two stacked commits, both shifting state OUT of the ViewModel and INTO the value returned by transform.

Commit 1 — fold cancellables into Output<Publishers>

VM's cancellables bag is removed. transform(input:) -> Output<Publishers> returns the publisher bag plus an [AnyCancellable] built via @BindingsBuilder. SceneController retains them.

Commit 2 — remove BaseViewModel, fold navigation into Output<Publishers, NavigationStep>

Output gains a second generic for the navigation step and an AnyPublisher<Step, Never> field. AbstractViewModel gains a 4th generic param. BaseViewModel is deleted entirely. Navigator<Step> is instantiated locally inside transform — the VM holds nothing.

Consumer-facing diff (SignUpViewModel)

Before:

final class SignUpViewModel: BaseViewModel<SignUpUserAction, InputFromView, Output> {
    let service: SignUpServicing
    init(service: SignUpServicing) { self.service = service; super.init() }

    override func transform(input: Input) -> Output {
        let activity = ActivityIndicator()
        input.fromView.submitTrigger
            .map { ... }
            .sink { [weak self] user in self?.navigator.next(.signedUp(user)) }
            .store(in: &cancellables)
        return Output(isSubmitEnabled: ..., isLoading: ...)
    }
}

After:

final class SignUpViewModel: AbstractViewModel<
    InputFromView,
    InputFromController,
    SignUpViewModel.Publishers,
    SignUpUserAction
> {
    let service: SignUpServicing
    init(service: SignUpServicing) { self.service = service; super.init() }

    override func transform(input: Input) -> Output<Publishers, SignUpUserAction> {
        let navigator = Navigator<SignUpUserAction>()
        let activity  = ActivityIndicator()
        return Output(
            publishers: Publishers(isSubmitEnabled: ..., isLoading: ...),
            navigation: navigator.navigation
        ) {
            input.fromView.submitTrigger
                .map { ... }
                .sink { [navigator] user in navigator.next(.signedUp(user)) }
        }
    }
}

Coordinator-side change

Coordinating+Scene+{Push,Present,Replace}.swift now subscribe via scene.navigation (a publisher re-exposed by SceneController after transform returns) instead of viewModel.navigator.navigation. The where V.ViewModel: Navigating constraints are gone — NavigationStep is an associated type on ViewModelType now.

Tradeoffs (full list)

Wins:

  • VM is truly stateless. No cancellables, no navigator.
  • One return type carries everything: publishers + navigation + subscriptions.
  • BaseViewModel and the Navigating-on-VM conformance disappear.
  • [navigator] strong-capture is honest now — SceneController owns the lifetime.

Costs:

  • AbstractViewModel grows from 3 → 4 generics. Consumer declarations are wordier (recoverable with a per-app typealias).
  • Big API break: every consumer transform override changes shape; every coordinator push(...) constraint stops requiring Navigating.
  • Late navigation subscription: scene.navigation only valid after transform runs. Already implicitly true today (push subscribes after scene init), but now visible.
  • Consumer's Publishers struct collides with Combine.Publishers enum (mild — Swift resolves to the nested type at use sites).
  • ~10 docstring example blocks still reference the old BaseViewModel<…> shape (InputFromController.swift, InputType.swift, ActivityIndicator.swift, ErrorTracker.swift, Publisher+Operators.swift, Coordinating.swift — non-code, non-build-blocking). Cleanup pass needed before any merge.

Test plan

  • just test passes (26 Core + Combine + DIPrimitives).
  • just example-build succeeds.
  • Pre-commit + pre-push hooks pass.
  • CI green.

Discussion points

  1. Worth the 4th generic on AbstractViewModel + breaking-API churn for "VM is stateless"?
  2. Publishers name vs Bindings? (Combine collision is mild but real.)
  3. Should Navigator<Step> stay as an opt-in helper, or should we just use PassthroughSubject directly?
  4. Provide a per-app typealias convenience (e.g. typealias SceneViewModel<FromView, Publishers, Step> = AbstractViewModel<FromView, InputFromController, Publishers, Step>) shipped as a public typealias in NanoViewControllerController?

🤖 Generated with Claude Code

Sajjon and others added 4 commits May 15, 2026 20:03
Replaces the mutable `cancellables` bag on `AbstractViewModel` with an
explicit `Output<Publishers>` return type. `transform(input:)` now
returns a value carrying both the publisher bag the view binds and
every subscription started inside `transform`; `SceneController`
retains the cancellables for the scene's lifetime.

Consumer call site:

    override func transform(input: Input) -> Output<Publishers> {
        // … intermediates …
        return Output(
            publishers: Publishers(isLoading: …, isSubmitEnabled: …)
        ) {
            input.fromView.submitTrigger
                .sink { [navigator] in navigator.next(.signedUp($0)) }
        }
    }

The trailing closure is `@BindingsBuilder`-annotated, so each `.sink`
is a statement, not `.store(in: &cancellables)`. The consumer-facing
publisher-bundle struct is renamed `Output` → `Publishers`, matching
the wrapper's terminology.

Other moves:

  * `BindingsBuilder` moves Combine → Core so the new `Output` type can
    use it without crossing the dep graph (Core has no upstream deps).
    Its tests move accordingly.
  * `ViewModelType.OutputVM` associated type renamed to `Publishers`.
  * `AbstractViewModel` / `BaseViewModel` rename their third generic
    param from `OutputFromViewModel`/`Output` → `Publishers`.
  * `populate(with:)` now takes `ViewModel.Publishers` directly.
  * Example app + tests + docstrings updated to match.

Exploration branch — not for merge as-is; this is meant to feel out
whether the ergonomics are worth the rename + breaking API change vs
the current `cancellables`-on-VM model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…, Step>

Building on the previous Output<Publishers> commit. The navigation
publisher moves into the Output return value, mirroring how cancellables
already do — the VM now carries zero stored state (no `cancellables`, no
`navigator`).

  * `Output<Publishers, NavigationStep>` gains the navigation publisher.
  * `AbstractViewModel` gains a 4th generic parameter (`NavigationStep`).
    A `NavigationStep == Never` convenience `Output.init` keeps no-nav
    scenes terse.
  * `BaseViewModel` is removed. Consumers spell out `AbstractViewModel<
    FromView, InputFromController, Publishers, Step>` directly.
  * `Navigating` protocol survives, but only for coordinators
    (`BaseCoordinator` still owns its own `navigator`). ViewModels no
    longer conform.
  * `Navigator<Step>` becomes an opt-in local helper instantiated *inside*
    `transform` (kept around for its thread-safety hop). Consumers can
    swap in a raw `PassthroughSubject<Step, Never>` instead.
  * `SceneController` stores the `Output.navigation` publisher and
    re-exposes it as `scene.navigation` for coordinator subscription.
  * `Coordinating+Scene+{Push,Present,Replace}.swift` subscribe via
    `scene.navigation` instead of `viewModel.navigator.navigation`. The
    `where V.ViewModel: Navigating` constraints disappear — the
    `NavigationStep` associated type now comes from `ViewModelType`.
  * Example app (SignUpDemo) + tests + key docstrings updated.

Late-subscription caveat: the coordinator can only subscribe to
`scene.navigation` *after* `transform` runs (inside scene init), so
`transform` must not emit navigation synchronously during construction.
This was already implicitly true today — `pushSceneInstance` triggers
scene init (and therefore `transform`) before subscribing to the VM's
navigator — so no behavioural regression.

Still on the exploration branch — not for merge as-is. Stacked on top of
the earlier "fold cancellables into Output" commit so the design can be
reviewed as one piece.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Survey of Zhip's ~25 scene view-models found zero usage of any
non-InputFromController FromController — every scene-bound VM uses
InputFromController, and the one VM that doesn't (a sub-VM owned by
its parent view) doesn't subclass anything at all.

So the FromController generic is dead weight in practice. Pinning it
drops a generic from AbstractViewModel:

    AbstractViewModel<FromView, FromController, Publishers, Step>
                                     ↓
    AbstractViewModel<FromView,                 Publishers, Step>

Same 3-generic shape as the old BaseViewModel had.

Surface changes:

  * AbstractViewModel moves Core → Controller (it now imports
    InputFromController). The ViewModelType protocol stays in Core
    so sub-VMs can conform without UIKit.
  * NoControllerInput removed (unused). The `ViewModelled where
    Input.FromController == NoControllerInput` convenience disappears
    with it.
  * AbstractViewModel.Input.fromController is now typed
    InputFromController directly — no more associated-type
    indirection at the subclass call site.
  * SceneController and the Scene typealias keep their
    `where ... FromController == InputFromController` constraint
    because they're parameterised on a generic View, not on
    AbstractViewModel — the constraint disambiguates the input shape.
  * AbstractViewModelTests move from CoreTests to a new
    NanoViewControllerControllerTests target so they can build
    against the now-Controller-resident class. Tests construct a
    real InputFromController via a small helper.

Consumer-facing diff:

    // Before
    public final class SignUpViewModel: BaseViewModel<
        SignUpUserAction, SignUpInputFromView, SignUpOutput
    > {  /* stores `cancellables` and `navigator` */ }

    // After
    public final class SignUpViewModel: AbstractViewModel<
        SignUpInputFromView, SignUpViewModel.Publishers, SignUpUserAction
    > {  /* no stored state */ }

Same 3 generic args, no base class, no stored bag, no stored navigator.

Tests: 112 across Core / Combine / DIPrimitives / Controller — all
green. Example app builds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fixes from a multi-agent review of the three exploration commits:

  * SceneController no longer carries the IUO
    `transformedNavigation!`. Replaced with a `PassthroughSubject` set
    up at construction time and forwarded into by `bindViewToViewModel`
    — coordinators see a stable publisher regardless of ordering.
  * Extracted `subscribeToNavigation` + `subscribeToModalNavigation`
    helpers on `Coordinating` so the three `Coordinating+Scene+*`
    overloads stop duplicating the same `scene.navigation.sinkOnMain {
    … }.store(in: cancellables)` block.
  * Folded `setup()` into `init` — single-caller speculative wrapper.
  * `Output where NavigationStep == Never` convenience init delegates
    to the designated init instead of copying field assignments.
  * `cancellables.formUnion(...)` replaces `.forEach { $0.store(in:) }`
    in `SceneController.bindViewToViewModel`.
  * Trimmed change-history narration from doc comments in
    `AbstractViewModel`, `Navigating`, `Output`, and the
    `SignUpViewModel` example.
  * Tests: deleted the no-op `test_input_initStitchesBothChannels`
    (asserted nothing); tightened the navigation test to assert
    `steps == [.finished]` rather than `steps.count == 1`.

Skipped findings (judged out of scope or over-engineering):
removing the now-vestigial `Navigating` protocol;
adding `Output.init(...navigator:)` overload coupling Core→Navigation;
shipping an `AnyPublisher.never` helper; promoting the test fixture
`makeStubInputFromController` onto `InputFromController` itself.

Tests: 111 across Core / Combine / DIPrimitives / Controller — green.
Example app builds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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