Changelog · latest 3.2.0
Scaffolding 3.2.0.
A simplification release. Destination tracking is now governed by
a single rule — auto-track by return type, opt out with @ScaffoldingIgnored. The @ScaffoldingTracked opt-in macro and the
concrete-coordinator return-type heuristic are both gone.
What's new in 3.2.0
- Breaking The
@ScaffoldingTrackedmacro is removed. There is no longer an explicit opt-in mode — destinations are always derived from the function's return type. - Breaking Concrete coordinator return types are no longer auto-tracked.
A child-coordinator route must return
any Coordinatable; a bare-> LoginCoordinatoris now skipped like any other unrecognised type. - New One mental model: a function is a destination iff its return
type is in the auto-tracked table
(
some View,any Coordinatable, or a tab tuple). Everything else — properties,Voidhelpers, closures, generics, arrays — is ignored automatically;@ScaffoldingIgnoredis only for the function that returns a tracked type but isn't a route (e.g.customize(_:)).
// 3.1 — three ways to control tracking.
@ScaffoldingTracked // explicit opt-in (removed)
func detail() -> some View { DetailView() }
func login() -> LoginCoordinator { ... } // concrete type, auto-tracked (removed)
// 3.2 — one rule. Return an auto-tracked type → it's a destination.
// Opt out with @ScaffoldingIgnored. Nothing else.
func detail() -> some View { DetailView() } // tracked
func login() -> any Coordinatable { LoginCoordinator() } // tracked (existential)If you never used @ScaffoldingTracked and always
returned any Coordinatable for child coordinators —
the documented guidance — this release is source-compatible. The
only call sites that need a change are concrete-typed coordinator
routes (switch them to any Coordinatable) and any @ScaffoldingTracked annotations (delete them).
What's new in 3.1.0
- New
present(_:as:onDismiss:)ships a<T: Coordinatable>overload on every coordinator type. The trailing closure receives the freshly-presented child, ready to seed before SwiftUI commits the sheet or full-screen cover.
// 3.1 — present(_:as:) gains a `<T: Coordinatable>` overload
// that hands you the resolved child once the modal lands.
func openSubscriptionAt(planId: String) {
present(.subscription, as: .sheet) { (sub: SubscriptionCoordinator) in
// Seed the freshly-presented sub-flow before SwiftUI commits
// the sheet — no IDs to forward, no @Environment lookups.
sub.preselect(planId: planId)
}
}Same shape as route(to:_:) / setRoot(_:_:) — the cast only fires when the
resolved destination matches T, so pick the
concrete coordinator type returned by the route.
Earlier — Scaffolding 3.0
A focused breaking release. Push and modal presentation are now
separate APIs, view becomes a property, the
generic value-callback overloads are gone, and a long list of
navigation-state bugs got fixed. The migration checklist below
applies to 2.x → 3.0; 3.0 → 3.1.0 is source-compatible.
Highlights
- Breaking
coordinator.view()is now the propertycoordinator.view. - Breaking
route(to:as:)is split into push-onlyroute(to:)and modal-onlypresent(_:as:). - Breaking The deep-link overloads
(
route<T>,setRoot<T>,appendTab<T>,insertTab<T>,popToFirst<T>,popToLast<T>,selectFirstTab<T>,selectLastTab<T>,select<T>(index:)/select<T>(id:)) keep their behaviour but tighten their signature:Tis now constrained toCoordinatable, the trailing closure drops thevalue:label, androuteloses itsas:parameter. - New
present(_:as:)is now available onTabCoordinatableandRootCoordinatable, not justFlowCoordinatable. - New
@Scaffoldable(injectsCoordinator: Bool = true)opts a coordinator out of automatic environment injection. - Fix
setRootnow wires up the parent chain on the new root and clears staleNavigationStackstate from the previous root. - Fix
dismissCoordinator()no longer leaks via retain cycles or leaves stale parent references behind. - Platform Deployment floor bumped to iOS 18 / macOS 15 / tvOS 18 /
watchOS 11 / macCatalyst 18. Strict-concurrency callbacks
(
onDismiss, deep-link trailing closures) are now@MainActor-typed.
view() → view
Mounting a coordinator no longer goes through a function call. view is a SwiftUI-friendly computed property on
every coordinator type, so it composes the same way any other
view does — no parens, no method semantics to think about.
// 2.x
@main
struct MyApp: App {
@State private var coordinator = AppCoordinator()
var body: some Scene {
WindowGroup {
coordinator.view() // function call
}
}
}// 3.0
@main
struct MyApp: App {
@State private var coordinator = AppCoordinator()
var body: some Scene {
WindowGroup {
coordinator.view // computed property — no parens
}
}
}Same change applies wherever the old API was reached for — #Preview, UIKit hosting controllers, snapshot
tests, etc.
route(to:as:) split into route and present
The old route(to:as:) overloaded a single call
with three different transitions (push / sheet / cover). The
signature looked uniform but the call sites read ambiguously:
you had to remember whether the destination represented a
screen meant to be pushed or a flow meant to be shown
modally. 3.0 makes the distinction explicit at the API level.
// 2.x — one route() did everything.
coordinator.route(to: .detail(item: planet)) // push (default)
coordinator.route(to: .settings, as: .sheet) // sheet
coordinator.route(to: .login, as: .fullScreenCover) // full-screen cover// 3.0 — push and present are explicitly different calls.
coordinator.route(to: .detail(item: planet)) // push only
coordinator.present(.settings, as: .sheet) // sheet
coordinator.present(.login, as: .fullScreenCover) // full-screen cover
// route(to:) no longer takes `as:` — pushes are the only thing it does.
// present(_:as:) accepts ModalPresentationType (sheet / fullScreenCover)
// at the call site; push is not a valid modal style by definition.present(_:as:) takes a new ModalPresentationType (only .sheet and .fullScreenCover) so the type system also
rules out the nonsensical "modal push." The richer PresentationType still exists internally for the
unified-stack model that powers pop().
present(_:as:) on every coordinator type
Previously only FlowCoordinatable could present a
modal. In 3.0 every coordinator type
(FlowCoordinatable, TabCoordinatable, RootCoordinatable) implements present(_:as:), so a tab coordinator or root
coordinator can host a sheet directly without delegating to a
synthetic child flow.
// 3.0 — present(_:as:) is now available on every coordinator type.
@Scaffoldable @Observable
final class MainTabCoordinator: @MainActor TabCoordinatable {
var tabItems = TabItems<MainTabCoordinator>(tabs: [.feed, .profile])
func feed() -> (any Coordinatable, some View) { /* … */ }
func profile() -> (any Coordinatable, some View) { /* … */ }
func upgrade() -> any Coordinatable { UpgradeCoordinator() }
func showUpgrade() {
present(.upgrade, as: .sheet) // tab coordinator hosts the sheet
}
}dismissCoordinator() on the presented coordinator
still dismisses the modal — pop() on the host
does the same when the modal is the topmost destination
(unified stack).
Deep-link overloads tightened
Every navigation method that resolves a child coordinator
(route, present, setRoot, appendTab, insertTab, popToFirst, popToLast, selectFirstTab, selectLastTab, select(index:), select(id:)) ships
a <T: Coordinatable> overload that hands you
a typed reference to the resolved child once the route lands.
That's the building block for deep linking:
you walk the tree by chaining one overload per layer.
The overloads themselves are still here in 3.0 — what changed is their shape:
Tis now constrained toCoordinatable. View destinations don't need typed access (they have nothing to drive), so the constraint cleans up the call site without losing functionality.- The trailing closure drops the
value:label. It's now an unlabelled trailing closure, which reads naturally as "after routing, do this with the resolved child." routedropsas:(the modal/push split). Pushes useroute(to:); modal entries viapresent(_:as:)have their own<T: Coordinatable>overload too.- Closures are
@MainActor-typed, in line with the rest of the API.
// 2.x — `<T>` was unconstrained, the closure was labelled `value:`,
// and `route` carried the `as:` parameter that's now on `present`.
appCoordinator.setRoot(.authenticated, value: { (tab: MainTabCoordinator) in
tab.selectFirstTab(.profile, value: { (profile: ProfileCoordinator) in
profile.route(to: .userDetail(id: 123), as: .push)
})
})// 3.0 — `T` is constrained to Coordinatable, the closure trails
// without a label, and `route` no longer takes `as:`. Same pattern,
// tighter signature.
appCoordinator.setRoot(.authenticated) { (tab: MainTabCoordinator) in
tab.selectFirstTab(.profile) { (profile: ProfileCoordinator) in
profile.route(to: .userDetail(id: 123))
}
}The pattern itself hasn't changed — just the spelling. See Deep linking in the overview for a full walk-through and an example that lands on a profile screen from a cold launch.
Macro: injectsCoordinator
By default @Scaffoldable wires the coordinator
into the SwiftUI environment so any view in its hierarchy can
read it via @Environment(MyCoordinator.self).
Sometimes that's the wrong default — a reusable coordinator
embedded in a component shouldn't necessarily expose itself
to surrounding views. Pass injectsCoordinator: false to opt out:
// 3.0 — opt this coordinator out of automatic environment injection.
@Scaffoldable(injectsCoordinator: false) @Observable
final class ReusableCoordinator: @MainActor FlowCoordinatable {
var stack = FlowStack<ReusableCoordinator>(root: .home)
func home() -> some View { ReusableHomeView() }
}The macro emits a nonisolated var _injectsCoordinator member so the framework can read the flag at runtime without
the user having to forward it themselves.
Bug fixes
setRootstale state. The new root's parent chain was not always wired before the old root'sNavigationStackran one more update pass, leaving stale push state visible during the swap.setRootnow resolves the new root atomically and clears the navigation stack of the previous one.dismissCoordinator()retain cycles. The presenter held a strong reference to the presented coordinator'sonDismiss, which itself captured the presenter — collapse-from-modal kept the whole sub-tree alive. The presenter ↔ presented edge is now resolved through a one-shot resolver that drops the back-reference once dismissal completes.- Parent chain on root coordinatables.
FlowStackandRootnow callsetParenton the root coordinatable so child flows installed at the root can dismiss themselves and read the environment chain. FlowStack.setRootnil-unwrap crash. Replacing the root with a destination whosecoordinatableresolved lazily could crash on a forced unwrap. The optional is now handled.- Nested-coordinator pushes invisible to
NavigationStack. A child flow's pushed destinations could be filtered out of the parent's flat path under specific traversal orderings. Fixed by recursing through nested coordinator boundaries when flattening destinations. - Swift 6 strict-concurrency cleanup. Generated
Destinationsenum conformances no longer carry redundant@MainActor; closures and callbacks that touch coordinator state are@MainActor-typed at their declaration sites.
Compatibility
| Requirement | 2.x | 3.0 |
|---|---|---|
| Swift toolchain | 6.2 | 6.2 |
| iOS | 17 | 18 |
| macOS | 14 | 15 |
| tvOS / watchOS / macCatalyst | 13 / 6 / 13 | 18 / 11 / 18 |
@Observable | required | required |
| Strict concurrency | opt-in | required (@MainActor-typed callbacks) |
TabRole | iOS 18 + @available | baseline (no annotation needed) |
The semver bump is driven by the API changes; the platform bump is the secondary breaking concern (consumers built against iOS 17 / macOS 14 won't link).
Migration checklist
- Required Replace every
coordinator.view()call with the property accesscoordinator.view(drop the parens). - Required Replace
route(to: x, as: .sheet)/.fullScreenCoverwithpresent(x, as: .sheet)/.fullScreenCover. Keeproute(to: x)for pushes; theas:argument no longer exists onroute. - Required Re-spell the deep-link
<T>overloads. Drop thevalue:label (the closure is now an unlabelled trailing closure), make sure the typed parameter is aCoordinatable(view-typedTs don't compile any more), and remove theas:argument fromroute. The overload still exists everywhere it did before, including a typed variant onpresent(_:as:)that hands you the resolved child once the modal lands. - Compiler
onDismissand the deep-link trailing closures are now@MainActor-typed. If you stored or forwarded one as a plain() -> Void/(T) -> Void, update the type or annotate the closure. - Platform Bump the consuming app's deployment target to iOS 18 / macOS 15 (and tvOS 18 / watchOS 11 / macCatalyst 18 if you ship those). The package no longer links against the old floors.
- Optional If you have coordinators whose views shouldn't see them in the environment, opt out with
@Scaffoldable(injectsCoordinator: false). - Optional Modal hosting can be moved up the tree where it makes sense —
present(_:as:)now works onTabCoordinatableandRootCoordinatabledirectly, so you no longer have to delegate every modal to a child flow.
Once the call-site changes are in, the underlying mental
model is unchanged: coordinators own navigation state, views
read coordinators from @Environment, route(to:) pushes, present(_:as:) shows a modal, dismissCoordinator() closes a
sub-flow.