Skip to content

docs: host-owned toast presentation design (working draft) - #14

Draft
robmaceachern wants to merge 1 commit into
mainfrom
robmaceachern/host-owned-toasts-design-doc
Draft

docs: host-owned toast presentation design (working draft)#14
robmaceachern wants to merge 1 commit into
mainfrom
robmaceachern/host-owned-toasts-design-doc

Conversation

@robmaceachern

Copy link
Copy Markdown
Member

Overview

🚧 Working draft — not a final proposal. This PR adds a design document intended to start a conversation about the imperative toast presentation model, not to conclude one. The API sketches are illustrative; the "Semantics to settle" section is genuinely unsettled. Filed as a draft PR so discussion can happen inline on the text.

Adds Documentation/design/host-owned-toast-presentation.md, which:

  • Collects the observable problems with modeling imperative toasts as view-controller-owned aggregated modals: lifetime coupling to the presenting VC, the must-retain ModalLifetime footgun, fatalError/silent-no-op failure modes, and consumers (MarketSwiftUI) reconstructing host internals to escape all three
  • Proposes design tenets: host-owned presentation state, host-side retention, best-effort failure semantics, environment + attribution captured at the call site
  • Stages migration through feat: add HostToastPresenting for host-scoped toast presentation #13 (HostToastPresenting), which remains the near-term unblock and is deletable under this design
  • Lists the load-bearing open questions (scoping policy, environment snapshots vs live updates, queueing, ObjC exposure) and rejected alternatives

The declarative (Workflow rendering) toast path is explicitly untouched.

Checklist

  • Unit Tests — N/A, documentation only
  • Documentation
  • Pull request title follows conventional commits

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>
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
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