Explore: Output<Publishers> wrapper replaces cancellables-on-VM#11
Draft
Sajjon wants to merge 4 commits into
Draft
Explore: Output<Publishers> wrapper replaces cancellables-on-VM#11Sajjon wants to merge 4 commits into
Sajjon wants to merge 4 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
cancellablesintoOutput<Publishers>VM's
cancellablesbag is removed.transform(input:) -> Output<Publishers>returns the publisher bag plus an[AnyCancellable]built via@BindingsBuilder.SceneControllerretains them.Commit 2 — remove
BaseViewModel, fold navigation intoOutput<Publishers, NavigationStep>Outputgains a second generic for the navigation step and anAnyPublisher<Step, Never>field.AbstractViewModelgains a 4th generic param.BaseViewModelis deleted entirely.Navigator<Step>is instantiated locally inside transform — the VM holds nothing.Consumer-facing diff (SignUpViewModel)
Before:
After:
Coordinator-side change
Coordinating+Scene+{Push,Present,Replace}.swiftnow subscribe viascene.navigation(a publisher re-exposed bySceneControlleraftertransformreturns) instead ofviewModel.navigator.navigation. Thewhere V.ViewModel: Navigatingconstraints are gone —NavigationStepis an associated type onViewModelTypenow.Tradeoffs (full list)
Wins:
cancellables, nonavigator.BaseViewModeland theNavigating-on-VM conformance disappear.[navigator]strong-capture is honest now — SceneController owns the lifetime.Costs:
AbstractViewModelgrows from 3 → 4 generics. Consumer declarations are wordier (recoverable with a per-app typealias).transformoverride changes shape; every coordinatorpush(...)constraint stops requiringNavigating.scene.navigationonly valid aftertransformruns. Already implicitly true today (push subscribes after scene init), but now visible.Publishersstruct collides withCombine.Publishersenum (mild — Swift resolves to the nested type at use sites).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 testpasses (26 Core + Combine + DIPrimitives).just example-buildsucceeds.Discussion points
AbstractViewModel+ breaking-API churn for "VM is stateless"?Publishersname vsBindings? (Combine collision is mild but real.)Navigator<Step>stay as an opt-in helper, or should we just usePassthroughSubjectdirectly?typealias SceneViewModel<FromView, Publishers, Step> = AbstractViewModel<FromView, InputFromController, Publishers, Step>) shipped as a public typealias inNanoViewControllerController?🤖 Generated with Claude Code