Skip to main content

incular_runtime/
route_outlet.rs

1//! Hosts one navigator's routes as mounted, identity-tagged content.
2//!
3//! [`RouteOutlet`] is the production seam between a navigation stack and a
4//! window tree: it composes every mounted route's content per an explicit
5//! [`OutletPlacement`] policy, drives focus save/restore/forget and route
6//! task bindings from live navigator state, and derives element ownership
7//! from the mounted tags — applications never maintain element-id oracles.
8//!
9//! Drive contract: mount [`RouteOutlet::widget`] in the window tree,
10//! attach once with [`RouteOutlet::attach`], then drive every cycle with
11//! the single [`RouteOutlet::present_frame`] operation — rebuild, frame,
12//! and reconcile in enforced order, with tree-wide validation before and
13//! after the frame. Covered routes without retention unmount (disposal);
14//! retained ones stay mounted inert while invisible; only permanent
15//! removal ends lifetimes.
16//!
17//! Frame contract: a frame runs build, layout, composite, semantics, then
18//! paint, so restoration — which reconciles after the frame returns —
19//! cannot make that same frame's paint or semantics. The restore applies
20//! to the focus slot synchronously and flags follow-up work through the
21//! existing scheduler (`set_focus` marks another frame); the next frame
22//! then finalizes styling, semantics, and keyboard dispatch from restored
23//! focus. No unconditional extra frame runs: follow-up is flagged when
24//! reconciliation changed focus, or when the presented output is older
25//! than live navigation (mid-build navigation flags exactly once per
26//! revision, nested staleness wakes the root driver, and quiet frames
27//! flag nothing). Errors schedule no follow-up at all, so rejected
28//! stacks cannot spin a retry loop. Deferred content converges through
29//! its own invalidation afterwards.
30//!
31//! Driving states (derived from the retained fields, not tracked
32//! separately — `pending`, `consumed` vs live revision, `needs_frame`,
33//! and the per-revision schedule memo):
34//!
35//! | Situation | pending | consumed | needs_frame | scheduled | exit |
36//! | Unattached, empty | none | none | false | — | navigate → stale |
37//! | Unattached, routes live | per capture | older/none | true | on success | attach → attached |
38//! | Attached, idle | none | current | false | — | navigate → pending |
39//! | Attempt pending | some | just composed | false, true after mid-frame nav | — | commit → consumed; stale → follow-up |
40//! | Failed (error/panic) | kept | older | true | reset, re-evaluates | retry → pending/consumed |
41//! | Successful-but-stale | recaptured/none | older | true | once per rev | follow-up → consumed |
42//! | Detached | kept (own) | frozen | own tree only | own claims | reattach or drive standalone |
43
44use std::{
45    cell::{Cell, RefCell},
46    collections::HashMap,
47    rc::{Rc, Weak},
48    sync::atomic::{AtomicU64, Ordering},
49};
50
51use incular_config::Constraints;
52use incular_navigation::{ModalBarrier, ModalRoute, Navigator, Route, RouteId, RoutePresentation};
53use incular_rendering::DisplayList;
54use incular_widgets::{
55    AnimatedModalBarrier, ExcludeFocusTraversal, LayoutBuilder, SizedBox, Stack, Visibility,
56    Widget,
57    internal::{ElementId, Key, TreeError, WidgetTree},
58};
59
60use super::{
61    frame::{FrameStats, Runtime},
62    tasks::TaskScope,
63};
64use crate::{RouteTaskBinding, route_focus::RouteFocusState};
65
66/// Sequence numbering outlet key namespaces so sibling outlets sharing one
67/// tree never tag two routes alike.
68static OUTLET_SEQUENCE: AtomicU64 = AtomicU64::new(1);
69
70/// How the outlet presents one route, derived from its [`RoutePresentation`].
71///
72/// Modes the outlet cannot honor are explicit variants, never silent
73/// behavior: overlay entries are portal-managed and never outlet-mounted.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum OutletPlacement {
76    /// Mounted stack content. `visible` paints, hits, and exposes
77    /// semantics; invisible content stays mounted inert while the route
78    /// retains state. `barrier` veils content below while mounted.
79    Stacked {
80        visible: bool,
81        barrier: Option<ModalBarrier>,
82    },
83    /// Portal-managed overlay entries: host them in an `OverlayPortal`
84    /// separately instead of mounting them here.
85    ExternalOverlay,
86}
87
88impl OutletPlacement {
89    /// Classifies `presentation` given whether any opaque route above
90    /// covers it. Only opaque pages occlude, so translucent veils dim
91    /// painted content rather than blackness.
92    pub fn of(presentation: &RoutePresentation, visible: bool) -> Self {
93        if presentation.is_overlay() {
94            return Self::ExternalOverlay;
95        }
96        Self::Stacked {
97            visible,
98            barrier: presentation.barrier().cloned(),
99        }
100    }
101}
102
103/// The navigation state one frame attempt builds content from: the
104/// authoritative stack revision plus the ordered route identities the
105/// frame composes. Immutable once captured; refreshed (never re-read for
106/// focus) at every build boundary so retries describe the content they
107/// actually present. Presentation is deliberately not snapshotted: it is
108/// fixed for a route identity, so revision plus identities is
109/// authoritative — no boolean approximation.
110#[derive(Clone, Debug, PartialEq, Eq)]
111struct FrameAttempt {
112    revision: u64,
113    active: Option<RouteId>,
114    members: Vec<RouteId>,
115}
116
117impl FrameAttempt {
118    fn capture(navigator: &Navigator) -> Self {
119        Self::of(navigator.revision(), &navigator.routes())
120    }
121
122    /// Snapshots exactly these routes at this revision: [`RouteOutlet::widget`]
123    /// builds the attempt from the same route list it composes children
124    /// from, so acquisition and composition stay together.
125    fn of(revision: u64, routes: &[Route]) -> Self {
126        Self {
127            revision,
128            active: routes.last().map(|route| route.id),
129            members: routes.iter().map(|route| route.id).collect(),
130        }
131    }
132
133    /// Whether the live navigator still matches this attempt closely
134    /// enough to commit. Same revision means nothing moved. Otherwise the
135    /// member set must match under the same active route: order-only
136    /// churn (a lower-route reorder beneath an unchanged top) and
137    /// same-identity child replacement keep the per-route bookkeeping
138    /// valid, while membership changes — pushes, pops, removals —
139    /// abandon. Push-then-pop back to the original top with no pending
140    /// transition never reaches the commit; with one in flight the ids
141    /// differ and the capture abandons cleanly.
142    fn covers(&self, navigator: &Navigator) -> bool {
143        if navigator.revision() == self.revision {
144            return true;
145        }
146        let current = Self::capture(navigator);
147        current.active == self.active
148            && current.members.len() == self.members.len()
149            && self.members.iter().all(|id| current.members.contains(id))
150    }
151}
152
153/// One uncommitted frame transition, tracking four independent concerns:
154/// - the outgoing focus save (`previous`/`saved`), which survives retries
155///   untouched — rebuilding re-records consumption without re-reading;
156/// - the navigation snapshot for this attempt (`attempt`), adopted from
157///   what composition actually consumed (see `consumed`);
158/// - newer-work detection, via the consumed revision versus live state
159///   (see [`RouteOutlet::needs_frame`]);
160/// - lifetime eligibility, checked at commit (incoming) and at restore
161///   (records never restore for removed routes).
162///
163/// The save lives here — not in the records — until the frame presenting
164/// it commits, so failed attempts and navigation mid-frame can never
165/// disturb committed state.
166#[derive(Clone, Debug, PartialEq, Eq)]
167struct PendingTransition {
168    previous: Option<RouteId>,
169    incoming: Option<RouteId>,
170    saved: Option<ElementId>,
171    attempt: FrameAttempt,
172}
173
174/// Identity of one [`RouteOutlet`], for actionable error data and host
175/// bookkeeping. Assigned once per outlet; sibling outlets sharing one
176/// tree never collide.
177#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
178pub struct OutletId(u64);
179
180impl std::fmt::Display for OutletId {
181    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        write!(formatter, "route-outlet-{}", self.0)
183    }
184}
185
186/// A route outlet refusing to present.
187///
188/// Timing differs per variant, and the docs say which: pre-frame
189/// rejections change nothing mounted or committed, while the post-frame
190/// validation can fire after the tree mounted the frame's content — in
191/// that case integration state (records, bindings, tags, saves) is still
192/// untouched, but the tree holds the last frame's stack output. Hosts fix
193/// the stack (portal-mount the listed routes, or keep them out of
194/// outlet-driven navigators) instead of debugging silently omitted
195/// content.
196#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
197pub enum OutletError {
198    /// One or more routes use portal-managed overlay presentations, which
199    /// the outlet never mounts. Carries the offending outlet alongside
200    /// the route ids, so nested rejections point at the responsible
201    /// navigator instead of the driving root.
202    #[error(
203        "{outlet} cannot present portal-managed overlay routes: {routes:?} \
204         (host overlay entries in an OverlayPortal separately)"
205    )]
206    UnsupportedPresentation {
207        outlet: OutletId,
208        routes: Vec<RouteId>,
209    },
210    /// Another live runtime already drives this outlet: the outlet is
211    /// attached to (or last presented by) `owner`, so presenting through
212    /// `attempted` would split one lifecycle across two frame loops.
213    /// Detach or drop the first runtime to release the claim — teardown
214    /// releases it automatically — then drive here.
215    #[error(
216        "{outlet} is already driven by runtime-{owner}: refusing runtime-{attempted} \
217         (one lifecycle per runtime; release the first claim before driving here)"
218    )]
219    DriverConflict {
220        outlet: OutletId,
221        owner: u64,
222        attempted: u64,
223    },
224    /// This outlet nests under a live parent: drive the root instead.
225    /// Separate presents would drive the child twice per frame (once
226    /// directly, once through the cascade) and corrupt the pending
227    /// capture. Detach first to drive it standalone.
228    #[error(
229        "{outlet} nests under {parent}: drive the root instead of presenting \
230         the child separately (detach first to drive it standalone)"
231    )]
232    SeparateDrive { outlet: OutletId, parent: OutletId },
233    /// The underlying rebuild or frame failed.
234    #[error("route outlet frame failed: {0}")]
235    Frame(#[from] TreeError),
236}
237
238/// Owns cleanup for one driven attempt. Armed before the fallible
239/// section; success disarms after flagging, while every early return —
240/// and panic unwind — drops it armed and resets the schedule memos.
241/// What survives a failed attempt, by design: the outgoing save and the
242/// pending capture (untouched — retry needs them), the published
243/// snapshot (no promotion ran), the driver claim (predates the attempt),
244/// and live navigation itself. Only the schedule memo resets, so the
245/// next success re-evaluates outstanding work. Panics resume unwinding
246/// immediately after the reset: integration invariants are restored,
247/// never repaired, and application panics never become successful
248/// frames.
249struct AttemptGuard<'a> {
250    outlet: &'a Rc<RefCell<RouteOutlet>>,
251    armed: bool,
252}
253
254impl<'a> AttemptGuard<'a> {
255    fn arm(outlet: &'a Rc<RefCell<RouteOutlet>>) -> Self {
256        Self {
257            outlet,
258            armed: true,
259        }
260    }
261
262    fn disarm(&mut self) {
263        self.armed = false;
264    }
265}
266
267impl Drop for AttemptGuard<'_> {
268    fn drop(&mut self) {
269        if self.armed {
270            self.outlet.borrow().reset_signaled_tree();
271        }
272    }
273}
274
275/// Why a nested outlet attachment was rejected.
276///
277/// Rejection happens before any mutation, so a failed attach leaves both
278/// outlets exactly as they were. The topology is a forest by design —
279/// like back dispatch it must terminate, but unlike back dispatch (which
280/// follows a single active chain and can share children) cascade driving
281/// visits every registered child, so one child under two parents would
282/// drive twice per frame.
283#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
284pub enum OutletAttachError {
285    /// An outlet cannot nest inside itself.
286    #[error("a route outlet cannot nest inside itself")]
287    SelfAttachment,
288    /// The child already reaches the parent through nested attachments,
289    /// so the edge would close a directed cycle and cascade driving
290    /// could recurse through it forever.
291    #[error(
292        "attaching this outlet would cycle cascade driving: \
293         it already reaches its parent"
294    )]
295    Cycle,
296    /// The child is already attached to a different live parent. Detach
297    /// it explicitly first: cascade driving follows registration, so a
298    /// second parent would drive the same outlet twice per frame.
299    #[error(
300        "this outlet is already attached to a different parent: \
301         detach it explicitly before reattaching"
302    )]
303    AlreadyAttached,
304}
305
306/// Mounts one [`Navigator`] as presentation-aware stack content.
307///
308/// The outlet owns the integration state the navigator cannot: per-route
309/// mount tags feeding the focus ownership oracle, a [`RouteFocusState`]
310/// driven across transitions, and one [`RouteTaskBinding`] per live
311/// route (lifetime-owned, including unmounted-but-alive routes). Use one
312/// outlet per navigator; nested navigators get nested
313/// outlets and stay isolated through disjoint tags and states.
314///
315/// Dropping the outlet releases the integration: focus records, bindings,
316/// and tags go with it (bound scopes then follow ordinary [`TaskScope`]
317/// ownership, detached from later removals).
318///
319/// State inventory (owner → invariant): `navigator` (cloned handle —
320/// identity and lifetimes live in navigation); `focus`, `bindings`,
321/// `tags` (outlet — pruned to live routes on every reconcile);
322/// `nested`/`parent` (attachment graph — weak both ways, single-parent,
323/// acyclic); `driver` (driving runtime — claimed on drive, released on
324/// detach or runtime drop); `task_parent` (host scope — never mutated);
325/// `presented`/`pending` (transition bookkeeping — advance only on
326/// commit); `epoch` (attempt context — one per driven frame);
327/// `composing`/`consumed` (composition record — receipted mounting
328/// builders only, promoted on success only); `signaled`
329/// (schedule memo — edge-triggered per revision); `namespace`/`root_key`
330/// (immutable
331/// construction identity); `tag_sequence` (monotonic, never reused);
332/// `revision` (invalidation generation — doubles as the drive counter);
333/// `mount` (attached element — cleared when it vanishes).
334pub struct RouteOutlet {
335    navigator: Navigator,
336    focus: RouteFocusState,
337    bindings: HashMap<RouteId, RouteTaskBinding>,
338    tags: Rc<RefCell<HashMap<RouteId, Key>>>,
339    /// Directly nested outlets, weakest first. Elements inside a nested
340    /// outlet belong to it even when an outer tag sits above them;
341    /// transitivity follows containment, so only direct children register.
342    /// Weak handles release automatically when a nested outlet drops, and
343    /// dead entries prune on every present, so repeated replacement never
344    /// grows metadata. Registration is what cascade driving follows: a
345    /// detached outlet receives no frame work even while retained.
346    nested: Rc<RefCell<Vec<Weak<RefCell<RouteOutlet>>>>>,
347    /// Whether route content is wrapped in a [`ModalRoute`] scope.
348    modal_route_scopes: bool,
349    /// This outlet's parent, if attached. Single-parent by design (see
350    /// [`OutletAttachError`]): set on attach, cleared on detach or when
351    /// the parent drops.
352    parent: Rc<RefCell<Option<Weak<RefCell<RouteOutlet>>>>>,
353    /// The runtime driving this outlet, with its liveness token. Claimed
354    /// on attach and on every driven present (roots and cascade children
355    /// alike); released on detach and automatically when the runtime
356    /// drops. No global registry: ownership lives in this claim plus the
357    /// runtime's own builder registration.
358    driver: RefCell<Option<(u64, Weak<u64>)>>,
359    task_parent: TaskScope,
360    /// Last successfully presented active route. Transitions capture
361    /// against this and advance it only on commit — never on failure.
362    presented: Option<RouteId>,
363    /// Uncommitted capture: `Some` while a frame transition is in flight.
364    pending: Option<PendingTransition>,
365    /// Frame attempt counter: bumped by every [`Self::begin_frame`].
366    /// Necessary context for [`Self::composing`], not proof of mounting:
367    /// only a receipt stamped by a mounting builder during the current
368    /// attempt authorizes adoption.
369    epoch: Cell<u64>,
370    /// Publication slot for in-flight composition: the mounting builders
371    /// write a receipt (attempt plus snapshot) here on every execution
372    /// (see [`Self::compose_and_record`]). Never read directly for
373    /// decisions — reconciliation promotes it (see `consumed`) only on
374    /// the success path for the current attempt, so failed composition
375    /// publishes nothing and discarded descriptors authorize nothing.
376    composing: RefCell<Option<(u64, FrameAttempt)>>,
377    /// Last composition a frame carried through reconciliation for this
378    /// outlet. Comparing it against live state answers whether newer
379    /// navigation still requires a frame (see [`Self::needs_frame`])
380    /// independently of the pending save above. Each outlet promotes its
381    /// own: nested slot builders promote when they execute and reconcile,
382    /// skipped builders keep the previous publication.
383    consumed: Option<FrameAttempt>,
384    /// Live revision last scheduled for. Follow-up scheduling is
385    /// edge-triggered per outlet: a stale revision flags exactly once no
386    /// matter how many frames observe it, so unconsumable staleness
387    /// (content no present can compose) idles instead of spinning. Reset
388    /// whenever a present fails: a failed frame consumes nothing, so the
389    /// next success must re-evaluate from scratch instead of inheriting
390    /// the failure's suppression.
391    signaled: Cell<u64>,
392    namespace: u64,
393    /// Monotonic tag counter. Freed tags are never reassigned (unlike the
394    /// map length, which shrinks on removal), so element identity can never
395    /// alias across removals. Interior so [`Self::widget`] shares by ref.
396    tag_sequence: Cell<u64>,
397    /// Mount tag on the outlet root, used to locate it for [`Self::attach`].
398    root_key: Key,
399    /// Invalidation generation bumped once per driven frame; nested
400    /// stateful slots observe it to rebuild outlet content (builder-owned
401    /// children survive ancestor rebuilds, so nested state and focus
402    /// persist across outer frames). Doubles as the drive counter read by
403    /// [`Self::frame_drive_count`]: one begin per present per outlet.
404    revision: Rc<Cell<u64>>,
405    /// Attached mount element, if [`Self::attach`] located the outlet root.
406    /// Cleared when the mount disappears so later frames degrade to
407    /// frame-plus-reconcile instead of erroring forever.
408    mount: Cell<Option<ElementId>>,
409}
410
411impl RouteOutlet {
412    /// Hosts `navigator`, parenting route task scopes under `task_parent`
413    /// (typically the window scope, so close still cancels through existing
414    /// ownership).
415    pub fn new(navigator: &Navigator, task_parent: &TaskScope) -> Self {
416        let tags: Rc<RefCell<HashMap<RouteId, Key>>> = Rc::default();
417        let tags_for_oracle = tags.clone();
418        let nested: Rc<RefCell<Vec<Weak<RefCell<RouteOutlet>>>>> = Rc::default();
419        let nested_for_oracle = nested.clone();
420        let focus = RouteFocusState::new(move |tree: &WidgetTree, id: ElementId| {
421            // Ancestor chain of the element, self included. Nested outlets
422            // win first: anything at or below a nested outlet root belongs
423            // to that outlet, even with an outer tag above it. Remaining
424            // attribution is by ancestry — sibling route subtrees never
425            // contain each other, so any matched own tag is the nearest.
426            // Scans stay bounded: one ancestor walk plus one reverse lookup
427            // per tag, on transitions and vacant retries only — never a
428            // per-route sweep. Dead handles simply miss.
429            let mut chain = Vec::new();
430            let mut cursor = Some(id);
431            while let Some(current) = cursor {
432                chain.push(current);
433                cursor = tree.parent(current);
434            }
435            for nested in nested_for_oracle.borrow().iter() {
436                let Some(nested) = nested.upgrade() else {
437                    continue;
438                };
439                let nested = nested.borrow();
440                if let Some(root) = tree.element_with_key(&nested.root_key)
441                    && (root == id || chain.contains(&root))
442                {
443                    return None;
444                }
445            }
446            tags_for_oracle.borrow().iter().find_map(|(route, key)| {
447                tree.element_with_key(key)
448                    .is_some_and(|root| chain.contains(&root))
449                    .then_some(*route)
450            })
451        });
452        let namespace = OUTLET_SEQUENCE
453            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
454            .expect("route outlet identity space exhausted");
455        Self {
456            navigator: navigator.clone(),
457            focus,
458            bindings: HashMap::new(),
459            tags,
460            nested,
461            modal_route_scopes: false,
462            parent: Rc::default(),
463            driver: RefCell::default(),
464            task_parent: task_parent.clone(),
465            presented: None,
466            pending: None,
467            epoch: Cell::new(0),
468            composing: RefCell::default(),
469            consumed: None,
470            signaled: Cell::new(0),
471            namespace,
472            // NOTE: no separate drive counter — `revision` below advances
473            // once per driven frame and serves as the drive count.
474            tag_sequence: Cell::new(0),
475            root_key: Key::String(format!("route-outlet-root-{namespace}")),
476            revision: Rc::new(Cell::new(0)),
477            mount: Cell::new(None),
478        }
479    }
480
481    /// Registers a directly nested outlet whose content mounts inside this
482    /// outlet's routes. Nested elements resolve to the nested outlet, so
483    /// inner navigation can neither overwrite outer saved focus nor be
484    /// restored over by it. Sibling outlets sharing only an ancestor need
485    /// nothing: disjoint subtrees never attribute across. Transitivity
486    /// follows containment — register each outlet with its direct parent.
487    ///
488    /// Topology is enforced before any mutation, borrowing the
489    /// back-dispatch lesson without coupling to it: self-attachment and
490    /// cycles are rejected (cascade driving must terminate), and each
491    /// outlet has at most one live parent (cascade driving visits every
492    /// registered child, so a shared child would drive twice per frame).
493    /// Re-registering with the same parent is an idempotent no-op;
494    /// moving parents requires [`Self::detach_nested`] first.
495    /// Registration holds weakly: dropping the nested outlet releases it,
496    /// and dead entries prune on every present, so repeated replacement
497    /// never grows metadata.
498    pub fn attach_nested(
499        outlet: &Rc<RefCell<Self>>,
500        nested: &Rc<RefCell<Self>>,
501    ) -> Result<(), OutletAttachError> {
502        if Rc::ptr_eq(outlet, nested) {
503            return Err(OutletAttachError::SelfAttachment);
504        }
505        if nested.borrow().reaches(outlet) {
506            return Err(OutletAttachError::Cycle);
507        }
508        {
509            let child = nested.borrow();
510            let mut parent = child.parent.borrow_mut();
511            if let Some(current) = parent.as_ref().and_then(Weak::upgrade) {
512                if Rc::ptr_eq(&current, outlet) {
513                    return Ok(());
514                }
515                return Err(OutletAttachError::AlreadyAttached);
516            }
517            // A dead parent link is just a leftover: detach cleared the
518            // registration but the child outlived this assignment.
519            *parent = Some(Rc::downgrade(outlet));
520        }
521        let outlet = outlet.borrow_mut();
522        let mut attached = outlet.nested.borrow_mut();
523        attached.retain(|existing| {
524            existing
525                .upgrade()
526                .is_some_and(|live| !Rc::ptr_eq(&live, nested))
527        });
528        attached.push(Rc::downgrade(nested));
529        Ok(())
530    }
531
532    /// Detaches a nested outlet. The child keeps its own records, bindings,
533    /// and tags, but the parent's cascade no longer drives it: a detached
534    /// outlet receives no frame work even while application code retains
535    /// its handle. Detach also releases the child's driver claim, so
536    /// ownership transfers cleanly — reattaching elsewhere (or driving it
537    /// standalone, including under another runtime) claims anew.
538    /// Detaching a non-child is a no-op.
539    pub fn detach_nested(outlet: &Rc<RefCell<Self>>, nested: &Rc<RefCell<Self>>) {
540        {
541            let child = nested.borrow_mut();
542            let mut parent = child.parent.borrow_mut();
543            let mine = parent
544                .as_ref()
545                .and_then(Weak::upgrade)
546                .is_some_and(|current| Rc::ptr_eq(&current, outlet));
547            if mine {
548                parent.take();
549                child.driver.borrow_mut().take();
550            }
551        }
552        {
553            let outlet = outlet.borrow_mut();
554            outlet.nested.borrow_mut().retain(|existing| {
555                existing
556                    .upgrade()
557                    .is_some_and(|live| !Rc::ptr_eq(&live, nested))
558            });
559        }
560    }
561
562    /// Whether `target` is reachable from this outlet by following nested
563    /// attachments. Dead entries are skipped, never traversed.
564    fn reaches(&self, target: &Rc<RefCell<Self>>) -> bool {
565        let mut visited: Vec<*const RefCell<Self>> = Vec::new();
566        let mut stack: Vec<Rc<RefCell<Self>>> = self.live_nested();
567        // The outlet itself counts: self-attachment is checked separately
568        // for its error, but a zero-length path must not read as a cycle.
569        while let Some(node) = stack.pop() {
570            if Rc::ptr_eq(&node, target) {
571                return true;
572            }
573            let address = Rc::as_ptr(&node);
574            if visited.contains(&address) {
575                continue;
576            }
577            visited.push(address);
578            stack.extend(node.borrow().live_nested());
579        }
580        false
581    }
582
583    /// Frames driven through this outlet, cascade included. Each
584    /// [`Self::present_frame`] on the driving root advances every
585    /// participating outlet by exactly one. Reads the invalidation
586    /// generation, which advances once per driven frame and needs no
587    /// separate counter.
588    #[must_use]
589    pub fn frame_drive_count(&self) -> u64 {
590        self.revision.get()
591    }
592
593    /// Mounts a nested outlet's widget with the required builder identity:
594    /// a stable keyed boundary driven by the nested revision, so ancestor
595    /// rebuilds retain the slot instead of remounting it. Hosts compose
596    /// this inside outer route content instead of assembling stateful
597    /// builders by hand; no nested `attach` is needed on top. Presenting
598    /// the outer outlet drives the whole tree in one frame — nested
599    /// outlets never need their own `present_frame` calls.
600    pub fn nested_widget(nested: &Rc<RefCell<Self>>) -> Widget {
601        let (revision, namespace) = {
602            let nested = nested.borrow();
603            (nested.revision.clone(), nested.namespace)
604        };
605        let nested_for_build = Rc::clone(nested);
606        Widget::stateful_builder(revision, move |_| {
607            nested_for_build.borrow().compose_and_record()
608        })
609        .with_key(Key::String(format!("route-outlet-slot-{namespace}")))
610    }
611
612    /// Number of live nested attachments. Dead handles prune on every
613    /// present; useful for hosts composing nested outlets dynamically.
614    #[must_use]
615    pub fn attached_nested_count(&self) -> usize {
616        self.nested
617            .borrow()
618            .iter()
619            .filter(|weak| weak.upgrade().is_some())
620            .count()
621    }
622
623    /// Live nested outlets for cascade driving.
624    fn live_nested(&self) -> Vec<Rc<RefCell<Self>>> {
625        self.nested
626            .borrow()
627            .iter()
628            .filter_map(Weak::upgrade)
629            .collect()
630    }
631
632    /// The live parent, if this outlet is attached under one.
633    fn live_parent(&self) -> Option<Rc<RefCell<Self>>> {
634        self.parent.borrow().as_ref().and_then(Weak::upgrade)
635    }
636
637    /// The live driver claim's runtime id, if a live runtime owns it. A
638    /// dead token means teardown released the claim: treated as unowned.
639    fn live_driver(&self) -> Option<u64> {
640        self.driver
641            .borrow()
642            .as_ref()
643            .and_then(|(id, token)| token.upgrade().is_some().then_some(*id))
644    }
645
646    /// Claims this outlet for `runtime`, superseding a dead owner's
647    /// leftover. Callers verify conflicts first.
648    fn claim_driver(&self, runtime: &Runtime) {
649        *self.driver.borrow_mut() = Some((runtime.id(), runtime.driver_token()));
650    }
651
652    /// Rejects conflicting drivers for the whole tree before any mutation:
653    /// the root must not nest under a live parent (drive the root
654    /// instead), and every participant must be unclaimed or claimed by
655    /// `runtime` — a live foreign claim fails with both identities.
656    fn check_drivers(&self, runtime: &Runtime, is_root: bool) -> Result<(), OutletError> {
657        if is_root && let Some(parent) = self.live_parent() {
658            return Err(OutletError::SeparateDrive {
659                outlet: self.id(),
660                parent: parent.borrow().id(),
661            });
662        }
663        if let Some(owner) = self.live_driver()
664            && owner != runtime.id()
665        {
666            return Err(OutletError::DriverConflict {
667                outlet: self.id(),
668                owner,
669                attempted: runtime.id(),
670            });
671        }
672        for nested in self.live_nested() {
673            nested.borrow().check_drivers(runtime, false)?;
674        }
675        Ok(())
676    }
677
678    /// Claims the whole tree for `runtime` after [`Self::check_drivers`]
679    /// passes. Refreshes matching claims harmlessly.
680    fn claim_tree(&self, runtime: &Runtime) {
681        self.claim_driver(runtime);
682        for nested in self.live_nested() {
683            nested.borrow().claim_tree(runtime);
684        }
685    }
686
687    /// Returns the navigator this outlet hosts.
688    /// Publishes each route's [`ModalRoute`] to its content, so
689    /// `ModalRoute::of` works inside routes. Off by default: the scope is
690    /// an inherited (lazily built) node, so route content then validates
691    /// when it mounts rather than before the outlet commits a rebuild, and
692    /// a malformed route reports `InvalidGeneratedChild` instead of failing
693    /// the rebuild atomically.
694    pub fn set_modal_route_scopes(&mut self, enabled: bool) {
695        self.modal_route_scopes = enabled;
696    }
697
698    #[must_use]
699    pub fn modal_route_scopes(&self) -> bool {
700        self.modal_route_scopes
701    }
702
703    #[must_use]
704    pub fn navigator(&self) -> &Navigator {
705        &self.navigator
706    }
707
708    /// Returns this outlet's identity, for error data and host bookkeeping.
709    #[must_use]
710    pub fn id(&self) -> OutletId {
711        OutletId(self.namespace)
712    }
713
714    /// Whether navigation arrived that no composition consumed yet — for
715    /// this outlet or any nested outlet. True before the first composition
716    /// with a non-empty stack, and after navigation newer than the last
717    /// consumed snapshot (including navigation during a build, whose paint
718    /// lags one frame behind the bookkeeping). False once composition
719    /// consumes current state. Diagnostics and custom drivers only:
720    /// ordinary hosts must not poll this for mandatory work — the
721    /// scheduler flag from `present_frame` carries that. It does not
722    /// report failed frames (their error return does that).
723    #[must_use]
724    pub fn needs_frame(&self) -> bool {
725        let stale = match &self.consumed {
726            None => !self.navigator.routes().is_empty(),
727            Some(consumed) => consumed.revision != self.navigator.revision(),
728        };
729        stale
730            || self
731                .live_nested()
732                .iter()
733                .any(|nested| nested.borrow().needs_frame())
734    }
735
736    /// Rejects this outlet's stack when it contains portal-managed overlay
737    /// routes, which the outlet never mounts. Pure check: no state changes
738    /// either way.
739    fn preflight(&self) -> Result<(), OutletError> {
740        let unsupported: Vec<RouteId> = self
741            .navigator
742            .routes()
743            .iter()
744            .filter(|route| route.presentation.is_overlay())
745            .map(|route| route.id)
746            .collect();
747        if unsupported.is_empty() {
748            Ok(())
749        } else {
750            Err(OutletError::UnsupportedPresentation {
751                outlet: self.id(),
752                routes: unsupported,
753            })
754        }
755    }
756
757    /// Validates the whole outlet tree top-down: this outlet, then every
758    /// nested outlet transitively. Pure check — no revisions, captures,
759    /// rebuilds, or frames happen on either outcome — so
760    /// [`Self::present_frame`] runs it before driving anything and again
761    /// after the frame (navigation may have moved mid-build) before
762    /// committing anything. Reports the outermost offender first.
763    fn preflight_tree(&self) -> Result<(), OutletError> {
764        self.preflight()?;
765        for nested in self.live_nested() {
766            nested.borrow().preflight_tree()?;
767        }
768        Ok(())
769    }
770
771    /// Attaches the outlet to its mounted widget so [`Self::present_frame`]
772    /// rebuilds outlet content every frame. Call once after mounting
773    /// [`Self::widget`]: the mount element is located through the outlet
774    /// root tag. Re-attaching replaces the previous builder harmlessly.
775    ///
776    /// Attachment claims the outlet for `runtime`'s driver domain: an
777    /// outlet nested under a live parent, or claimed by another live
778    /// runtime, is rejected before mutation. Detach (nested) or drop the
779    /// owning runtime (teardown releases) before attaching elsewhere.
780    ///
781    /// ```rust
782    /// use std::{cell::RefCell, rc::Rc};
783    /// use incular_config::Constraints;
784    /// use incular_core::{Color, Size};
785    /// use incular_navigation::{Navigator, Page};
786    /// use incular_runtime::{RouteOutlet, Runtime};
787    /// use incular_widgets::{Column, Widget};
788    ///
789    /// let navigator = Navigator::new();
790    /// let mut runtime =
791    ///     Runtime::new(Column::new(Vec::<Widget>::new()).into()).unwrap();
792    /// let parent = runtime.spawner().scope();
793    /// let outlet = Rc::new(RefCell::new(RouteOutlet::new(&navigator, &parent)));
794    /// // One-time setup: mount the outlet widget, then attach its builder.
795    /// let root = runtime.tree().root().expect("root");
796    /// let outlet_for_build = outlet.clone();
797    /// runtime
798    ///     .register_builder(root, move || {
799    ///         Column::new(vec![outlet_for_build.borrow().widget()]).into()
800    ///     })
801    ///     .unwrap();
802    /// runtime
803    ///     .run_frame(Constraints::tight(Size::new(200., 200.)))
804    ///     .unwrap();
805    /// RouteOutlet::attach(&outlet, &mut runtime).unwrap();
806    /// navigator.push_page(Page::new("a", Widget::box_(Size::new(40., 40.), Color::WHITE)));
807    /// // The single recurring operation: rebuild, frame, reconcile.
808    /// let _frame =
809    ///     RouteOutlet::present_frame(&outlet, &mut runtime, Constraints::tight(Size::new(200., 200.)))
810    ///         .unwrap();
811    /// assert!(navigator.current().is_some());
812    /// ```
813    pub fn attach(
814        outlet: &Rc<RefCell<RouteOutlet>>,
815        runtime: &mut Runtime,
816    ) -> Result<(), OutletError> {
817        {
818            let outlet = outlet.borrow();
819            if let Some(parent) = outlet.live_parent() {
820                return Err(OutletError::SeparateDrive {
821                    outlet: outlet.id(),
822                    parent: parent.borrow().id(),
823                });
824            }
825            if let Some(owner) = outlet.live_driver()
826                && owner != runtime.id()
827            {
828                return Err(OutletError::DriverConflict {
829                    outlet: outlet.id(),
830                    owner,
831                    attempted: runtime.id(),
832                });
833            }
834        }
835        let mount = {
836            let outlet = outlet.borrow();
837            runtime
838                .tree()
839                .element_with_key(&outlet.root_key)
840                .ok_or_else(|| {
841                    OutletError::Frame(TreeError::InvalidWidgetConfiguration {
842                        widget: "RouteOutlet",
843                        reason: "mount outlet.widget() in the tree before attaching".to_owned(),
844                    })
845                })?
846        };
847        // The builder carries no reactive dependencies: present_frame
848        // rebuilds it explicitly, so content never refreshes behind the
849        // transition bookkeeping's back.
850        let outlet_for_build = Rc::clone(outlet);
851        runtime
852            .register_builder(mount, move || {
853                outlet_for_build.borrow().compose_and_record()
854            })
855            .map_err(OutletError::Frame)?;
856        outlet.borrow().claim_driver(runtime);
857        outlet.borrow_mut().mount.set(Some(mount));
858        Ok(())
859    }
860
861    /// Builds the outlet widget: mounted routes in stack order with each
862    /// route's veil as a direct sibling preceding its content, so barrier
863    /// semantics block every earlier route while the veiled route itself
864    /// stays visible. The visible suffix paints from the top down to the
865    /// first opaque page inclusive; retained but covered routes stay
866    /// Builds the outlet widget for mounting, without touching any
867    /// bookkeeping: safe to call speculatively and discard. Only the
868    /// mounting builders (registered by `attach`, and nested slots)
869    /// acknowledge what they accept. Covered routes without retention
870    /// unmount (disposal); retained ones stay mounted inert while
871    /// invisible; overlay entries are left for their portal host.
872    #[must_use]
873    pub fn widget(&self) -> Widget {
874        self.compose().0
875    }
876
877    /// Composes the widget from the current routes alongside the immutable
878    /// snapshot it was built from. Pure: acquisition and composition stay
879    /// together here, publication happens only where the result mounts.
880    fn compose(&self) -> (Widget, FrameAttempt) {
881        let routes = self.navigator.routes();
882        let attempt = FrameAttempt::of(self.navigator.revision(), &routes);
883        // Top-down visibility: everything paints until (and including) the
884        // first opaque page.
885        let mut visible = vec![false; routes.len()];
886        let mut occluded = false;
887        for (index, route) in routes.iter().enumerate().rev() {
888            visible[index] = !occluded;
889            if route.presentation.is_opaque() {
890                occluded = true;
891            }
892        }
893        let mut children = Vec::new();
894        for (route, visible) in routes.iter().zip(visible) {
895            // Overlay entries never mount here: `present_frame` rejects
896            // their stacks typed (before and after the frame), so reaching
897            // this skip means setup-time composition ahead of validation —
898            // still omitted, never half-mounted, and never silent through
899            // the supported path.
900            let OutletPlacement::Stacked { visible, barrier } =
901                OutletPlacement::of(&route.presentation, visible)
902            else {
903                continue;
904            };
905            if !visible && !route.presentation.maintains_state() {
906                continue;
907            }
908            // Veil before content: semantic blocking hides preceding
909            // siblings only, so the veil must precede its route's content
910            // (paint and hit order still resolve top-down correctly, since
911            // the veil sits above every earlier route). Veils follow
912            // visibility: a covered retained route keeps its content but
913            // not an active veil — the veil would dim and intercept input
914            // for content the user can no longer see.
915            if visible && let Some(barrier) = barrier {
916                children.push(self.veil_widget(route.id, &barrier));
917            }
918            children.push(self.content_widget(route, visible));
919        }
920        // The outlet root carries the mount tag so `attach` locates it;
921        // route tags live on the per-route wrappers inside. The root is
922        // always a stack — even empty — so mounting and unmounting the
923        // last route never flips the element kind.
924        let root: Widget = Stack::new(children).into();
925        (root.with_key(self.root_key.clone()), attempt)
926    }
927
928    /// Composes for mounting and publishes the consumed receipt: the only
929    /// path (besides [`Self::compose`]'s pure construction) that writes
930    /// [`Self::composing`], stamped with the current attempt. Called
931    /// solely by the mounting builders, so a discarded or speculative
932    /// descriptor can never authorize a frame.
933    fn compose_and_record(&self) -> Widget {
934        let (widget, attempt) = self.compose();
935        *self.composing.borrow_mut() = Some((self.epoch.get(), attempt));
936        widget
937    }
938
939    /// Composes one mounted route's content inside the outlet's own
940    /// visibility wrapper carrying the mount tag. The tag sits on the
941    /// outlet-owned wrapper — never on the presented child — so caller
942    /// keys on route content survive navigation untouched, and the tag
943    /// element stays stable across visibility flips.
944    fn content_widget(&self, route: &Route, visible: bool) -> Widget {
945        // `ModalRoute.of` for everything the route presents, when enabled.
946        let content = if self.modal_route_scopes {
947            Widget::environment_scope(
948                ModalRoute::new(route.clone(), self.navigator.clone()),
949                route.presented_child(),
950            )
951        } else {
952            route.presented_child()
953        };
954        let mut wrapped: Widget = Visibility::new(content)
955            .visible(visible)
956            .maintain_state(true)
957            .into();
958        if !visible {
959            wrapped = ExcludeFocusTraversal::new(wrapped).into();
960        }
961        wrapped.with_key(self.tag_for(route.id))
962    }
963
964    /// Veils the outlet area for one route through the shared animated
965    /// barrier mechanism: unconditional pointer interception, background
966    /// semantics blocked for every preceding sibling, and dismissal that
967    /// pops the barrier's own route — never whatever happens to be on top.
968    /// The veil is a direct stack sibling preceding its content (not nested
969    /// inside it) so semantic blocking reaches across routes while the
970    /// route's own content stays visible; it is sized to the outlet area
971    /// because an unsized barrier container collapses and lets taps fall
972    /// through to the supposedly blocked content.
973    fn veil_widget(&self, route: RouteId, barrier: &ModalBarrier) -> Widget {
974        let navigator = self.navigator.clone();
975        let barrier = barrier.clone();
976        Widget::from(LayoutBuilder::new(move |_, constraints| {
977            // Bounded hosts (windows, dialogs) size the veil to the outlet
978            // area; unbounded heights fall back to the raw veil, which still
979            // blocks its own content but cannot cover an unknown area.
980            let size = constraints.biggest();
981            let navigator = navigator.clone();
982            let mut veil = AnimatedModalBarrier::with_flat_color(barrier.color)
983                .dismissible(barrier.dismissible);
984            if let Some(label) = barrier.label.clone() {
985                veil = veil.semantics_label(label);
986            }
987            let veiled: Widget = veil
988                .on_dismiss(move || {
989                    if navigator
990                        .current()
991                        .is_some_and(|current| current.id == route)
992                    {
993                        navigator.pop();
994                    }
995                })
996                .into();
997            if size.width.is_finite() && size.height.is_finite() {
998                SizedBox::from_dimensions(Some(size.width), Some(size.height), Some(veiled)).into()
999            } else {
1000                veiled
1001            }
1002        }))
1003        .block_semantics()
1004    }
1005
1006    /// Returns the route task scope for spawning route-bound work, if the
1007    /// route is currently mounted and bound.
1008    #[must_use]
1009    pub fn route_task_scope(&self, route: RouteId) -> Option<TaskScope> {
1010        self.bindings
1011            .get(&route)
1012            .map(|binding| binding.scope().clone())
1013    }
1014
1015    /// Presents one production frame through the outlet: validates the
1016    /// tree, captures any pending transition, rebuilds outlet content,
1017    /// runs layout, then commits the transition against the mounted tree.
1018    /// This is the supported recurring operation: the save runs before
1019    /// the frame reconciles (rebuilding may unmount covered content and
1020    /// the drain clears dead focus), while the restore runs after layout
1021    /// when targets are eligible.
1022    ///
1023    /// Takes the outlet shared (rather than `&mut self`) because the
1024    /// attached builder reenters it immutably while the frame runs; short
1025    /// borrows never span the frame. Driving claims the whole tree for
1026    /// `runtime`: presenting a nested child directly, or through a second
1027    /// live runtime, fails before any mutation. A rebuild or frame failure
1028    /// keeps the pending capture for a safe retry instead of consuming the
1029    /// activation, and navigation mid-frame abandons the stale capture
1030    /// without touching committed records.
1031    pub fn present_frame(
1032        outlet: &Rc<RefCell<Self>>,
1033        runtime: &mut Runtime,
1034        constraints: Constraints,
1035    ) -> Result<(DisplayList, FrameStats), OutletError> {
1036        // Unsupported stacks anywhere in the tree fail before revisions,
1037        // capture, rebuild, frame, or reconcile — nothing mounted or
1038        // committed changes on this path. Driver conflicts fail next, also
1039        // before any mutation; the claim lands only on this success path.
1040        outlet.borrow().preflight_tree()?;
1041        outlet.borrow().check_drivers(runtime, true)?;
1042        outlet.borrow().claim_tree(runtime);
1043        {
1044            let mut outlet = outlet.borrow_mut();
1045            outlet.begin_frame(runtime);
1046        }
1047        // The fallible section runs builders, layout, and reconciliation —
1048        // all application-reachable code. The attempt guard owns cleanup:
1049        // every early return — and panic unwind — drops it armed and
1050        // resets the schedule memos, since a failed present consumes
1051        // nothing and the next success must re-evaluate outstanding work.
1052        // Neither path repairs tree state — recovery stays the retry's
1053        // job.
1054        let mut attempt = AttemptGuard::arm(outlet);
1055        // Rebuild outlet content explicitly before framing: reactive
1056        // dependencies alone cannot cover content the host descriptors
1057        // never mention, and an explicit rebuild keeps mounting ordered
1058        // with the capture above. A vanished mount (unmounted content
1059        // prunes its builder) detaches gracefully instead of erroring —
1060        // or panicking — every frame. The existence check precedes the
1061        // rebuild because pruned builders no longer fail with
1062        // `MissingElement`.
1063        let attached = outlet
1064            .borrow()
1065            .mount
1066            .get()
1067            .filter(|mount| runtime.tree().element_exists(*mount));
1068        if attached.is_none() {
1069            outlet.borrow_mut().mount.set(None);
1070        }
1071        if let Some(mount) = attached {
1072            match runtime.rebuild_from_builder(mount) {
1073                Ok(()) => {}
1074                Err(TreeError::MissingElement(_)) => {
1075                    outlet.borrow_mut().mount.set(None);
1076                }
1077                Err(error) => return Err(OutletError::Frame(error)),
1078            }
1079        }
1080        // No post-rebuild sampling here: each outlet's builders publish
1081        // what they actually consume (see `consumed`), and reconciliation
1082        // adopts it. Sampling afterward would credit the rebuild with
1083        // navigation it never saw.
1084        let output = runtime.run_frame(constraints)?;
1085        // A preflight snapshot cannot authorize later content: builders
1086        // may have navigated mid-frame, so the tree re-validates before
1087        // anything commits. On failure the pending capture survives for a
1088        // retry (which preflights first anyway) and no commit, bind,
1089        // prune, or restore runs — the error names the offender instead
1090        // of silently omitting its content.
1091        outlet.borrow().preflight_tree()?;
1092        outlet.borrow_mut().reconcile_tree(runtime);
1093        attempt.disarm();
1094        // Successful-but-stale output schedules its own follow-up: paint
1095        // may lag the bookkeeping by a frame, and the run_frame reset
1096        // wipes anything flagged mid-frame. Quiet frames schedule
1097        // nothing; errors never reach here.
1098        outlet.borrow().flag_stale_frames(runtime);
1099        Ok(output)
1100    }
1101
1102    /// Captures a pending transition when none is outstanding and the
1103    /// active route moved since the last commit. The outgoing focus is
1104    /// read once into the pending slot — never into the records — alongside
1105    /// the attempt snapshot, so a later retry cannot overwrite it with
1106    /// intermediate focus, and abandoning the capture drops it without
1107    /// touching committed state.
1108    fn capture_transition(&mut self, runtime: &Runtime) {
1109        if self.pending.is_some() {
1110            return;
1111        }
1112        let active = self.navigator.current().map(|route| route.id);
1113        if active == self.presented {
1114            return;
1115        }
1116        let saved = self.presented.and_then(|previous| {
1117            self.navigator
1118                .lifetime_of(previous)
1119                .is_some()
1120                .then(|| self.focus.read_owned_focus(runtime, previous))
1121                .flatten()
1122        });
1123        self.pending = Some(PendingTransition {
1124            previous: self.presented,
1125            incoming: active,
1126            saved,
1127            attempt: FrameAttempt::capture(&self.navigator),
1128        });
1129    }
1130
1131    /// Commits the pending transition when the frame actually presented
1132    /// it: the active route still matches the captured incoming route, the
1133    /// incoming lifetime is alive, and the attempt snapshot still covers
1134    /// the navigator — so the mounted content is the transition's own.
1135    /// Anything else — navigation mid-frame beyond order/child-only
1136    /// changes, or a removed incoming route — abandons the capture and
1137    /// re-captures fresh, leaving committed records alone. Returns whether
1138    /// a transition committed.
1139    fn commit_transition(&mut self, runtime: &mut Runtime) -> bool {
1140        let active = self.navigator.current().map(|route| route.id);
1141        let Some(pending) = self.pending.take() else {
1142            return false;
1143        };
1144        if active != pending.incoming
1145            || pending
1146                .incoming
1147                .is_some_and(|incoming| self.navigator.lifetime_of(incoming).is_none())
1148            || !pending.attempt.covers(&self.navigator)
1149        {
1150            // Stale capture (already taken above): re-capture fresh for the
1151            // route that actually won, without writing anything first.
1152            self.capture_transition(runtime);
1153            return false;
1154        }
1155        if let Some(previous) = pending.previous {
1156            self.focus.commit_saved(previous, pending.saved);
1157        }
1158        self.presented = active;
1159        self.pending = None;
1160        true
1161    }
1162
1163    /// Drops records, bindings, and tags for permanently removed routes.
1164    /// Lifetime-end notifications already fired at commit time, so bound
1165    /// scopes were cancelled before their bindings drop here. Unmounted
1166    /// but live routes (covered, retained, disposal-unmounted) keep
1167    /// everything: removal is a lifetime event, not a mount event.
1168    fn prune_removed(&mut self) {
1169        self.focus.retain_mounted(&self.navigator);
1170        self.bindings
1171            .retain(|id, _| self.navigator.lifetime_of(*id).is_some());
1172        self.tags
1173            .borrow_mut()
1174            .retain(|id, _| self.navigator.lifetime_of(*id).is_some());
1175    }
1176
1177    /// Binds every live route lacking a binding. Scope ownership follows
1178    /// the route lifetime — not mounted content: covered, retained, and
1179    /// even disposal-unmounted routes stay bound while alive, so their
1180    /// tasks outlive their pixels; only permanent removal ends them.
1181    /// Focus records are the mounted-content side of the integration
1182    /// (attributed through the live tree); bindings are the lifetime side
1183    /// (keyed by navigator state, including routes with no pixels).
1184    fn ensure_bindings(&mut self) {
1185        for route in self.navigator.routes() {
1186            if !self.bindings.contains_key(&route.id)
1187                && let Some(lifetime) = self.navigator.lifetime_of(route.id)
1188            {
1189                self.bindings.insert(
1190                    route.id,
1191                    RouteTaskBinding::bind(&self.task_parent, &lifetime),
1192                );
1193            }
1194        }
1195    }
1196
1197    /// Restores on committed transitions; otherwise retries a pending
1198    /// restore only when it cannot displace valid focus (deferred content
1199    /// may have mounted or enabled through ordinary invalidation since).
1200    fn restore_or_retry(&mut self, runtime: &mut Runtime, transitioned: bool) {
1201        let Some(id) = self.navigator.current().map(|route| route.id) else {
1202            return;
1203        };
1204        if transitioned {
1205            self.focus.restore_saved(runtime, &self.navigator, id);
1206        } else {
1207            self.focus.restore_if_vacant(runtime, &self.navigator, id);
1208        }
1209    }
1210
1211    /// Promotes the in-flight composition into the published snapshot and
1212    /// adopts it into the pending attempt — without touching the outgoing
1213    /// save — so the commit judges built content, and retries describe
1214    /// their own build. Runs only on the success path (reconciliation),
1215    /// so failed composition publishes nothing; skipped builders simply
1216    /// re-promote their previous publication. Only mounting builders
1217    /// write the slot, and only the current attempt's receipt adopts: a
1218    /// stale receipt (failed attempt, predated capture) authorizes
1219    /// nothing even at a matching revision. Within the attempt, only
1220    /// newer-or-equal snapshots relabel. All values are immutable copies,
1221    /// so application callbacks running later in reconciliation cannot
1222    /// relabel built content.
1223    fn adopt_consumed(&mut self) {
1224        let Some((epoch, composing)) = self.composing.borrow().clone() else {
1225            return;
1226        };
1227        if epoch != self.epoch.get() {
1228            return;
1229        }
1230        if let Some(pending) = self.pending.as_mut()
1231            && composing.revision >= pending.attempt.revision
1232        {
1233            pending.attempt = composing.clone();
1234        }
1235        self.consumed = Some(composing);
1236    }
1237
1238    /// Schedules follow-up frames for stale output through the runtime's
1239    /// own invalidation (`request_frame`) — but only for revisions never
1240    /// scheduled before. Successful output older than live navigation
1241    /// still requires a frame, and ordinary hosts must not poll
1242    /// [`Self::needs_frame`] to discover it; the edge trigger keeps
1243    /// unconsumable staleness (and every quiet frame) from scheduling
1244    /// anything. Errors return before reconciliation, so rejected stacks
1245    /// schedule no retry loop. Nested staleness wakes the root driver
1246    /// through the same cascade. The per-revision suppression loses no
1247    /// outstanding demand: failures and panics reset it, and every new
1248    /// navigation (including slot remounts) carries a new revision.
1249    fn flag_stale_frames(&self, runtime: &mut Runtime) {
1250        let live = self.navigator.revision();
1251        let stale = match &self.consumed {
1252            None => !self.navigator.routes().is_empty(),
1253            Some(consumed) => consumed.revision != live,
1254        };
1255        if stale && self.signaled.get() != live {
1256            self.signaled.set(live);
1257            runtime.request_frame();
1258        }
1259        for nested in self.live_nested() {
1260            nested.borrow().flag_stale_frames(runtime);
1261        }
1262    }
1263
1264    /// Clears schedule memos across the tree. A failed present consumes
1265    /// nothing, so its suppression must not survive: the next success
1266    /// re-evaluates outstanding work from scratch.
1267    fn reset_signaled_tree(&self) {
1268        self.signaled.set(0);
1269        for nested in self.live_nested() {
1270            nested.borrow().reset_signaled_tree();
1271        }
1272    }
1273
1274    /// Post-frame reconciliation: adopt the consumed snapshot, commit the
1275    /// pending transition against it, then prune, bind, and restore or
1276    /// retry. The commit runs before pruning so a just-committed record
1277    /// for a route removed mid-frame is dropped rather than resurrected.
1278    fn reconcile_presented(&mut self, runtime: &mut Runtime) {
1279        self.adopt_consumed();
1280        let transitioned = self.commit_transition(runtime);
1281        self.prune_removed();
1282        self.ensure_bindings();
1283        self.restore_or_retry(runtime, transitioned);
1284    }
1285
1286    /// Bumps this outlet and every nested outlet, then captures pending
1287    /// transitions top-down. One call drives the whole outlet tree, so a
1288    /// single frame presents nested navigation without rendering the
1289    /// runtime once per outlet. The topology guarantees (acyclic,
1290    /// single-parent) make every participant reachable exactly once.
1291    fn begin_frame(&mut self, runtime: &Runtime) {
1292        self.epoch.set(self.epoch.get().wrapping_add(1));
1293        self.revision.set(
1294            self.revision
1295                .get()
1296                .checked_add(1)
1297                .expect("revision exhausted"),
1298        );
1299        self.capture_transition(runtime);
1300        for nested in self.live_nested() {
1301            nested.borrow_mut().begin_frame(runtime);
1302        }
1303    }
1304
1305    /// Reconciles this outlet and every nested outlet after a frame,
1306    /// children first so the outer context wins focus ties
1307    /// deterministically. Dead nested handles prune here, keeping
1308    /// registrations bounded no matter how often hosts replace outlets.
1309    fn reconcile_tree(&mut self, runtime: &mut Runtime) {
1310        for nested in self.live_nested() {
1311            nested.borrow_mut().reconcile_tree(runtime);
1312        }
1313        self.reconcile_presented(runtime);
1314        self.nested
1315            .borrow_mut()
1316            .retain(|weak| weak.upgrade().is_some());
1317    }
1318
1319    /// Stable mount tag for a route, assigned once while the outlet lives.
1320    /// Tags are never reused, so a re-pushed route mounts fresh even under
1321    /// the same page key.
1322    fn tag_for(&self, route: RouteId) -> Key {
1323        if let Some(key) = self.tags.borrow().get(&route) {
1324            return key.clone();
1325        }
1326        let mut tags = self.tags.borrow_mut();
1327        if let Some(key) = tags.get(&route) {
1328            return key.clone();
1329        }
1330        let sequence = self.tag_sequence.get();
1331        self.tag_sequence.set(sequence.wrapping_add(1));
1332        let key = Key::String(format!("route-outlet-{}-{sequence}", self.namespace));
1333        tags.insert(route, key.clone());
1334        key
1335    }
1336}