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(¤t, 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(¤t, 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}