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