Restoration, awaited navigation, and a testing library
3.3.0 shipped dismissModal() and the shouldSelect hook. Everything below has landed since — the quality-of-life pass on the navigation API, navigation-state snapshots, async/await navigation, and a second library for testing coordinators.
Navigation state snapshots
Capture the live coordinator tree and replay it on the next launch: current root, tab selection, every flow's push stack, and presented modals, recursively.
@Scaffoldable(codable: true) @Observable
final class HomeCoordinator: @MainActor FlowCoordinatable { … }
let data = try appCoordinator.captureNavigationState() // opaque Data — persist anywhere
try appCoordinator.restoreNavigationState(from: data) // replay onto a fresh tree@Scaffoldable(codable: true)makes the generatedDestinationsenumCodable; route payloads must then beCodablethemselves.- Coordinators that don't opt in degrade gracefully — their subtree restores at its initial position instead of failing. Only calling
captureNavigationState()directly on a non-codable coordinator throws (NavigationStateError.unsupported). - Routes that no longer decode after an app update are skipped, so a stale snapshot never breaks launch.
FlowStack(root:pushing:)seeds a known deep position without any persistence — useful for deep-link fallbacks, previews, and tests.
Awaited navigation
Navigation that reads like a function call, as an alternative to onDismiss: and onComplete: closures:
await routeAndWait(to: .categoryPicker) // resumes when it pops
await presentAndWait(.onboarding, as: .fullScreenCover) // resumes when it closes
guard let limit = await present(.limitPicker, awaiting: Decimal.self) else { return }The suspension resumes exactly once however the destination leaves — pop(), popToRoot(), a back swipe, a root swap, dismissModal(), or the coordinator being dismissed. Any dismissal that isn't dismissCoordinator(returning:) resumes with nil, so cancellation needs no extra code.
Navigation API
- Modal dismissal from the presenter:
dismissModal()closes the topmost modal and fires itsonDismissexactly once;dismissAllModals()clears the stack. Both are available on every coordinator type and never touch pushed destinations. - Presenter-side sheet configuration:
present(_:as: .sheet(detents:dragIndicator:interactiveDismissDisabled:)). The presented view stays ignorant of its presentation. RoutePolicy.distinctskips a route when the same case is already on top or already presented — a one-word double-tap guard.- New stack operations:
pop(_ count:)(stops at the root, never dismisses the coordinator) andreplaceLast(with:)(back skips the replaced screen). - Stack introspection:
depth,topDestination,count(of:),isPresentingModalalongside the existingisInStack(_:). - Tab badges:
setBadge(_:for:)(numeric or text,0/nilclears) andbadge(for:), plusisInTabItems(_:). expecting:overloads on every navigation call that resolves a child coordinator — a flat alternative to the typed trailing closures for deep-link chains.- Cross-coordinator results:
dismissCoordinator(returning:)hands a value to a presenter awaitingpresent(_:awaiting:).
Orientation and debugging
ancestor(ofType:)walks the parent chain,routeTypereports how a coordinator was presented (.root/.push/.sheet/.fullScreenCover, withisModal), andhierarchyRootreaches the top of the tree. These replace the untypedNavigatorenvironment value — views that only need to close or go back use SwiftUI's@Environment(\.dismiss).debugHierarchy()prints the live tree, side-effect free: a child that hasn't been created is reported, never materialised.hierarchySnapshot()returns that same tree as[HierarchyNode](role,meta,coordinator,hasCoordinator,children) for debug UIs and assertions.
ScaffoldingTesting
A new library product for test targets — never link it into an app target, since it imports Swift Testing.
let home = HomeCoordinator().activated() // resolve the initial root
home.open(transaction)
#expect(home.topDestination == .transaction)
app.handle(URL(string: "myapp://holding/NVDA")!)
#expect(app.hierarchyContains(InvestCoordinator.self, .holding, as: .push))activated()resolves the initial root/tabs that the framework would otherwise resolve on first render. Without it,topDestination,isRoot(_:), anddebugHierarchy()read as empty in tests.descendant(ofType:)/descendants(ofType:)return a typed handle on an already-created child — the only way into a flow the code under test presented itself, sincepresent(_:awaiting:)deliberately hands back no coordinator.hierarchyContains(_:_:)/(_:_:as:)assert on the whole tree with types instead of matchingdebugHierarchy()output. Roles are.root,.push,.sheet,.fullScreenCover,.tab(index:isSelected:).waitUntil { … }spins the main actor for the awaitable API and records an issue instead of hanging on timeout.
Macro
@Scaffoldablenow infers the coordinator kind from its state container (FlowStack/TabItems/Root) when the conformance is spelled through a protocol of your own —protocol TabFlow: FlowCoordinatablepreviously failed, because a macro sees syntax only.@Scaffoldable(codable:)joinsinjectsCoordinator:; both default to preserving current behaviour.
Fixed
- Tab badges did not refresh.
TabViewon iOS 26 stops re-evaluating its container body when a badge mutates on the observableTabItems; the badge is now part of the tab's render identity and mirrored through local state.
Documentation
-
Seven DocC tutorials instead of one, grouped into four chapters and all building the same app: fundamentals, tabs, authentication root swaps, modal sub-flows, deep linking, state restoration, testing. The fundamentals tutorial no longer teaches a
NavigationStacknested inside a flow's sheet, ordismissCoordinator()wheredismissModal()belongs. -
Async/await is the documented default: a new Awaiting Navigation section in the guide, with the tutorials teaching the awaited and flat
expecting:forms rather than closures. 11 stale doc links from the 3.3 API changes are fixed, and the catalog builds warning-free. -
README and
AGENTS.mdcover the full current surface. -
Agent skills. The agent guide is now five progressive-disclosure skills, and the repo doubles as a Claude Code plugin marketplace:
claude plugin marketplace add dotaeva/scaffolding && \ claude plugin install scaffolding@scaffolding
Example
Example/Demo replaces the three-screen sample with a banking-style app covering the whole surface: auth root swap, a tab coordinator with a custom glass bar and a gated tab, four independent flows, presented sub-flows returning values, sheet configuration, deep links, and snapshot save/restore. Its test target carries 44 Swift Testing cases over the shipping coordinators. It's a plain Xcode project — open Example/Demo/Demo.xcodeproj, then ⌘R or ⌘U.
Infrastructure
The hand-written Svelte site is gone; DocC alone is published to GitHub Pages, which moves the documentation to /scaffolding/documentation/scaffolding/.
Migration
Source-compatible from 3.3.0 with one exception: @Environment(\.navigator) and the Navigator type were removed. Use @Environment(\.dismiss) for close/back, or the typed coordinator (@Environment(MyCoordinator.self), ancestor(ofType:)) for anything else. Navigator was never part of a tagged release.