feat: add HostToastPresenting for host-scoped toast presentation - #13
Merged
Conversation
3 tasks
robmaceachern
force-pushed
the
robmaceachern/host-toast-presenting
branch
from
July 20, 2026 20:18
b221e61 to
3dcb62c
Compare
robmaceachern
force-pushed
the
robmaceachern/host-toast-presenting
branch
from
August 5, 2026 16:33
ad3148d to
d415785
Compare
robmaceachern
changed the base branch from
main
to
robmaceachern/invalidate-forwarded-presentations
August 5, 2026 16:33
robmaceachern
force-pushed
the
robmaceachern/host-toast-presenting
branch
from
August 5, 2026 17:08
d415785 to
0e79db2
Compare
3 tasks
robmaceachern
marked this pull request as ready for review
August 5, 2026 22:37
johnnewman-square
approved these changes
Aug 6, 2026
Comment on lines
+49
to
+54
| 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 } | ||
| } |
Contributor
There was a problem hiding this comment.
This is a pretty neat feature.
Base automatically changed from
robmaceachern/invalidate-forwarded-presentations
to
main
August 7, 2026 19:28
robmaceachern
added a commit
that referenced
this pull request
Aug 7, 2026
## Overview Fixes stale forwarded presentation snapshots when a filtered nested modal host attaches, detaches, or stops forwarding. This issue was uncovered while developing #13. `HostToastPresenting` relies on forwarded presentations being removed from a former host when ownership changes, so #13 is stacked on this fix. The invalidation behavior remains presentation-type agnostic and is independently useful for any forwarded presentation. ## Motivation A nested host forwards part of its aggregated `ModalList` to an ancestor. The ancestor keeps displaying that snapshot until it receives `setNeedsModalUpdate()`. Directly removing the host can invalidate the ancestor, but removing an intermediate container does not deliver containment callbacks to the nested host, leaving forwarded modals or toasts visible after their host has detached. ## Design - Weakly track the ancestor that last received forwarded presentations. - Invalidate both the former and current forwarding ancestors when host ownership changes. - Observe window attachment so indirect containment removal clears the former ancestor's snapshot. - Apply the same behavior to the UIKit and Workflow modal hosts. The fix is presentation-type agnostic and uses only the existing `ModalHost` aggregation contract. ## Validation - 57 unit tests passed: 43 Modals and 14 WorkflowModals. - New UIKit and Workflow coverage for attaching, direct detachment, indirect ancestor detachment, forwarding-ancestor changes, and stopping forwarding. - Toast tests verify storage remains retained while stale visibility is removed. - Modal tests verify the behavior is not toast-specific. - Samples scheme builds successfully. - SwiftFormat 0.55.0 passes. ## Checklist - [x] Unit Tests - [x] Documentation is not required; no public API changes - [x] Pull request title follows conventional commits
Adds a HostToastPresenting protocol that modal hosts conform to, vending a ToastPresenter (contentToastPresenter) that presents from the root of the host's content. Toasts presented this way are decoupled from the view controller that triggered them: they survive navigation, including removal of the triggering view controller, until dismissed via their ModalLifetime. Previously the only way to achieve this was to walk the view controller hierarchy to locate the host's content and present from it directly, which requires knowledge of the modal presentation system's internal containers. Both ModalHostContainerViewController and the WorkflowModals host conform; consumers reach the presenter via `rootModalHost as? HostToastPresenting`. Delegates to the content's existing ToastPresenter, so environment propagation, presentation filters, toast ordering, and lifetime semantics are unchanged from toasts presented from any descendent view controller. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
robmaceachern
force-pushed
the
robmaceachern/host-toast-presenting
branch
from
August 7, 2026 19:28
0e79db2 to
3700d83
Compare
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 a
HostToastPresentingprotocol that modal hosts conform to, vending aToastPresenter(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
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:HostToastPresentingis a separate protocol (rather than aModalHostrequirement) becauseModalHostis@objcand can't gain a defaulted Swift requirement, andToastPresenteris not@objc-representable.Notes
passThroughToastsfilter already forwards toasts to the outermost host.contentToastPresenterrather thantoastPresenterbecauseUIViewController.toastPresenteralready 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 makehost.toastPresenterfunctional 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 resolveViewEnvironmentfrom 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:contentToastPresentercan later be reimplemented on top of host-owned presentation — or deprecated in its favor — without breaking adopters.Known trade-offs
rootModalHost as? HostToastPresentingexists becauseModalHostis@objcand can't carry a defaulted Swift requirement (andToastPresenterisn't@objc-representable). A host that doesn't conform quietly lacks the capability; both in-tree hosts conform.modalHostandrootModalHostpick a starting point, not a display guarantee — the filter decides. Documented on the protocol.Checklist