docs: host-owned toast presentation design (working draft) - #14
Draft
robmaceachern wants to merge 1 commit into
Draft
docs: host-owned toast presentation design (working draft)#14robmaceachern wants to merge 1 commit into
robmaceachern wants to merge 1 commit into
Conversation
A discussion vehicle, not a final proposal: frames the problems with modeling toasts as view-controller-owned aggregated modals (lifetime coupling, must-retain tokens, fatal failure modes) and sketches a host-owned, best-effort presentation model, with migration staged through the HostToastPresenting accessor (#13). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 tasks
robmaceachern
added a commit
that referenced
this pull request
Aug 7, 2026
> **Stack:** This feature is based on #16, which contains the general forwarded-presentation invalidation fix and its lifecycle tests. This PR now contains only the `HostToastPresenting` API, documentation, and toast-specific owner-resolution coverage. ## Overview Adds a `HostToastPresenting` protocol that modal hosts conform to, vending a `ToastPresenter` (`contentToastPresenter`) that presents toasts from the root of the host's content — decoupling toast lifetimes from the view controller that triggered them. ## Motivation Toasts are fire-and-forget notifications: the canonical flows ("Item saved" while popping a screen, "Item deleted" while a sheet closes) present a toast precisely when the triggering UI is going away. Today's imperative path scopes toast lifetime to the presenting view controller — `viewController.toastPresenter.present(...)` stops being aggregated the moment that view controller leaves the hierarchy. The only current workaround is to walk the view controller hierarchy to find the view controller at the base of the host's content and present from it, which requires knowledge of the modal presentation system's internal container types. (MarketSwiftUI's in-progress toast API does exactly this walk today — see squareup/market#12597 — and this API would let it delete that code.) Presenting from the host itself doesn't work either: hosts aggregate starting at their *content* (`content.aggregateModals()`), so anything presented from the host's own presenter is never aggregated. ## Design ```swift public protocol HostToastPresenting: ModalHost { /// A `ToastPresenter` that presents toasts from the root of the host's content, decoupling /// their lifetime from any particular descendent view controller. var contentToastPresenter: ToastPresenter { get } } ``` Both hosts conform by delegating to their content's existing presenter (`content.toastPresenter`), so environment propagation, presentation filters, toast ordering, and lifetime semantics are exactly those of any content-presented toast — no aggregation or behavioral changes. The WorkflowModals host class is internal, so the protocol is the public surface; consumers reach it from any descendent view controller: ```swift if let host = rootModalHost as? HostToastPresenting { self.toastLifetime = host.contentToastPresenter.present(toastViewController) } ``` `HostToastPresenting` is a separate protocol (rather than a `ModalHost` requirement) because `ModalHost` is `@objc` and can't gain a defaulted Swift requirement, and `ToastPresenter` is not `@objc`-representable. ## Notes - Nested hosts ride the existing rails: the default `passThroughToasts` filter already forwards toasts to the outermost host. - Naming: `contentToastPresenter` rather than `toastPresenter` because `UIViewController.toastPresenter` already exists on host view controllers with different semantics (the host's own — never-aggregated — presenter). ## Alternatives considered **App-installed root anchor ("hoist it yourself").** The framework's standing guidance for toast-lifetime pain has been to present from a long-lived view controller you own, and an app *can* install its own anchor at its content root with no framework change. Two reasons that doesn't generalize: in Workflow apps the host's content view controller is internal to `WorkflowModals`, so there is no uniform way for a library (as opposed to each app) to reach a long-lived presentation point; and per-app root wiring produces N slightly-different implementations of the same thing. This PR is the one-line framework capability that makes the hoisting pattern implementable uniformly. **Host aggregates its own presenter.** A ~2-line change (`content.aggregateModals() + self.aggregatePresenterModals()`) would make `host.toastPresenter` functional directly. Rejected for its side effects: it would resurrect any currently black-holed presentations sitting in host trampolines after an upgrade; host-owned items would resolve `ViewEnvironment` from the host rather than the content subtree (wrong theming); it would implicitly legitimize host-level *modals*; and it needs a defined path through the WorkflowModals presentation-filter forwarding. Delegating to the content's existing presenter sidesteps all of that — semantics are byte-for-byte those of a content-presented toast. **First-class host-owned toast presentation.** The fuller design: the host owns imperative toast state directly (no trampoline, no aggregation dependency for display), retains presentations until dismissal (no drop-to-dismiss token footgun), and treats a missing host as a defined best-effort no-op rather than `fatalError` — toasts, unlike modals, leave no undefined state behind when presentation fails. That's a real architectural conversation (host-owned mutable state alongside the aggregation model, environment propagation for host-owned presentations), not a small PR — a working-draft design doc for it is up at #14. This accessor is deliberately compatible with it: `contentToastPresenter` can later be reimplemented on top of host-owned presentation — or deprecated in its favor — without breaking adopters. ## Known trade-offs - **Attribution.** Toasts presented through this accessor are owned by the host's content, so the view controller hierarchy no longer identifies which feature presented a given toast. Acceptable for transient notifications, but worth noting; a first-class API (above) could carry explicit source attribution. - **Capability by downcast.** `rootModalHost as? HostToastPresenting` exists because `ModalHost` is `@objc` and can't carry a defaulted Swift requirement (and `ToastPresenter` isn't `@objc`-representable). A host that doesn't conform quietly lacks the capability; both in-tree hosts conform. - **Scoping is inherited, not chosen.** The accessor rides existing presentation-filter semantics (pass-through forwards toasts to the outermost host). Callers choosing between `modalHost` and `rootModalHost` pick a starting point, not a display guarantee — the filter decides. Documented on the protocol. ## Checklist - [x] Unit Tests - [x] Documentation - [x] Pull request title follows conventional commits
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.
Overview
Adds
Documentation/design/host-owned-toast-presentation.md, which:ModalLifetimefootgun,fatalError/silent-no-op failure modes, and consumers (MarketSwiftUI) reconstructing host internals to escape all threeHostToastPresenting), which remains the near-term unblock and is deletable under this designThe declarative (Workflow rendering) toast path is explicitly untouched.
Checklist