Skip to main content

frust_widgets/nav/
controller.rs

1//! [`NavigatorController`]: the cloneable app-state handle that *records*
2//! navigation ops for a [`NavigatorWidget`]
3//! to drain at its next rebuild, plus the published depth / back-interest /
4//! transition / route-stack read seams the facade and app code observe it
5//! through.
6
7use std::cell::{Cell, RefCell};
8use std::rc::Rc;
9
10use frust_core::AnyView;
11
12use super::ambient::host_reachable;
13use super::options::{
14    BackPolicy, NavOp, NavigatorId, PopResult, PushOptions, ReplaceOptions, ResultCallback,
15};
16use super::route_state::RouteStack;
17use super::transition::{TransitionSpec, TransitionState};
18
19// Named only by the doc comments moved here with this module's items, so their
20// intra-doc links keep resolving to the same targets they did in `navigator`.
21#[allow(unused_imports)]
22use super::navigator::NavigatorWidget;
23#[allow(unused_imports)]
24use super::transition::PageTransition;
25#[allow(unused_imports)]
26use super::view::navigator;
27#[allow(unused_imports)]
28use frust_core::Widget;
29
30/// The app-state handle to a [`navigator`]: a cloneable op queue an app keeps in
31/// its `Component::State` and drives with [`push`](Self::push)/[`pop`](Self::pop)/
32/// [`replace`](Self::replace). Every clone shares one queue (`Rc`), so the handle
33/// the view carries and the handle event handlers call are the same.
34///
35/// Ops are *recorded*, not applied — the [`NavigatorWidget`] drains and applies
36/// them at its next rebuild (see the [module docs](self)).
37pub struct NavigatorController<State: 'static> {
38    pub(super) ops: Rc<RefCell<Vec<NavOp<State>>>>,
39    /// The current page-stack **depth**, published by the attached
40    /// [`NavigatorWidget`] on every `build`/`rebuild`/`apply_ops`. The widget
41    /// owns the authoritative stack; this shared cell is
42    /// the read seam [`depth`](Self::depth)/[`can_pop`](Self::can_pop) expose so
43    /// the facade's back handler (and app code) can ask "would a pop do
44    /// anything?" without reaching into the widget. `0` until a widget attaches.
45    ///
46    /// `frust-widgets` stays reactive-free: this is a plain `Rc<Cell<_>>`,
47    /// not a signal — a shell/facade polls it at rebuild time (see the timing
48    /// note in `frust-reactive::back`).
49    pub(super) depth: Rc<Cell<usize>>,
50    /// Whether a back press should be *claimed* by the navigator ahead-of-time
51    /// (predictive-back parity) — published by the attached
52    /// [`NavigatorWidget`] alongside [`depth`](Self::depth). `true` iff the stack
53    /// is poppable (`depth > 1`) **or** the top page's [`BackPolicy`] is not
54    /// [`Pop`](BackPolicy::Pop) (a dismissable/veto overlay claims back even at
55    /// the root) — **and** the page hosting this navigator is itself
56    /// input-reachable (the R23 gate; see
57    /// [`NavigatorWidget::reachable`], always satisfied for a top-level
58    /// navigator). This is the signal the facade's back handler computes
59    /// `handles_back` from — it differs from [`can_pop`](Self::can_pop) exactly
60    /// in the depth-1-with-overlay case, where a raw pop would do nothing but the
61    /// overlay still owns the press. Same plain `Rc<Cell<_>>` (reactive-free)
62    /// polled-at-rebuild contract as `depth`.
63    pub(super) back_interest: Rc<Cell<bool>>,
64    /// The published snapshot of the navigator's single in-flight page
65    /// transition, written by the attached [`NavigatorWidget`] at every
66    /// transition edge *and* on every paint frame that advances the driver. The
67    /// read seam is [`transition`](Self::transition), whose doc carries the
68    /// timing contract.
69    ///
70    /// Same reactive-free idiom as [`depth`](Self::depth)/
71    /// [`back_interest`](Self::back_interest) — a plain `Rc<Cell<_>>` of `Copy`
72    /// data, never a signal. Unlike those two it is published from *paint* as
73    /// well as build, which is what makes a frame-exact read possible.
74    pub(super) transition: Rc<Cell<TransitionState>>,
75    /// How many live [`NavigatorWidget`]s currently render this controller's
76    /// stack — incremented by [`NavigatorView::build`] and (if a live widget's
77    /// controller is swapped) by [`NavigatorView::rebuild`]; decremented by
78    /// `teardown` and by that same swap handling for the controller being
79    /// swapped *away from*. Read through [`is_mounted`](Self::is_mounted).
80    ///
81    /// A **liveness** seam, not a published-state one: the three cells above
82    /// answer "what does the stack look like?", this one answers "is this
83    /// navigator in the retained tree at all?". The facade's back arbitration
84    /// needs the latter to tell a navigator that merely did not wire this pass
85    /// from one whose screen was torn down (see `frust::back_glue`'s R44-back
86    /// prune). A count, not a bool, so a reconcile that builds the replacement
87    /// widget before tearing down the old one never reads as unmounted.
88    ///
89    /// **The widget never decrements this cell by looking `self.controller` up
90    /// again** — [`NavigatorWidget`] holds its own clone (`mounted`, alongside
91    /// `depth`/`back_interest`/`transition`), rebound only at `build` and at a
92    /// controller-swap `rebuild`, and every decrement goes through that field.
93    /// A `NavigatorView`'s `self.controller` is whatever the app currently
94    /// hands it — after a swap that is already the *new* controller — so
95    /// `teardown` reading it instead would double-unmount the new one and never
96    /// correct the old one, exactly the structural gap this field closes (see
97    /// [`NavigatorView::rebuild`]'s controller-swap comment).
98    ///
99    /// Same reactive-free `Rc<Cell<_>>` idiom as the rest of the controller.
100    pub(super) mounted: Rc<Cell<usize>>,
101    /// The [`PageEntry::reach`] cell of the page **hosting** this navigator,
102    /// bound by the attached [`NavigatorWidget`] at `build`/`rebuild`; `None`
103    /// for a top-level navigator (and until a widget attaches).
104    ///
105    /// The R23 back-reach gate [`back_interest`](Self::back_interest) reads
106    /// *live*, rather than a value baked into the published cell: the page
107    /// hosting a nested navigator can stop being input-routed on a pass in which
108    /// that navigator does not rebuild at all (a page frozen by
109    /// [`cull_covered_builds`](NavigatorView::cull_covered_builds), or one
110    /// stashed out of the stack by a pop transition), and a baked-in value would
111    /// go stale exactly there — the worst case for this gate.
112    ///
113    /// A `RefCell` slot around a plain `Rc<Cell<bool>>`, like `ops` — a
114    /// *binding* that moves when the widget re-captures its host, wrapping the
115    /// same reactive-free cell idiom as the published state.
116    pub(super) host_reach: Rc<RefCell<Option<Rc<Cell<bool>>>>>,
117    /// The published route-state snapshot — a fourth published slot
118    /// beside `depth`/`back_interest`/`transition`, written by
119    /// [`NavigatorWidget::publish_state`] after every committed stack
120    /// mutation (see `route_state`'s module docs for the staleness contract
121    /// and why an in-flight interactive edge swipe does not publish here).
122    ///
123    /// `RefCell`, not `Cell`: the payload (`RouteStack`) is not `Copy` —
124    /// precedented on this same type by [`ops`](Self::ops) and
125    /// [`host_reach`](Self::host_reach).
126    pub(super) route_stack: Rc<RefCell<RouteStack>>,
127}
128
129impl<State: 'static> Clone for NavigatorController<State> {
130    fn clone(&self) -> Self {
131        Self {
132            ops: Rc::clone(&self.ops),
133            depth: Rc::clone(&self.depth),
134            back_interest: Rc::clone(&self.back_interest),
135            transition: Rc::clone(&self.transition),
136            mounted: Rc::clone(&self.mounted),
137            host_reach: Rc::clone(&self.host_reach),
138            route_stack: Rc::clone(&self.route_stack),
139        }
140    }
141}
142
143impl<State: 'static> Default for NavigatorController<State> {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149impl<State: 'static> NavigatorController<State> {
150    /// A fresh controller with an empty op queue.
151    pub fn new() -> Self {
152        Self {
153            ops: Rc::new(RefCell::new(Vec::new())),
154            depth: Rc::new(Cell::new(0)),
155            back_interest: Rc::new(Cell::new(false)),
156            transition: Rc::new(Cell::new(TransitionState::default())),
157            mounted: Rc::new(Cell::new(0)),
158            host_reach: Rc::new(RefCell::new(None)),
159            route_stack: Rc::new(RefCell::new(RouteStack::default())),
160        }
161    }
162
163    /// Bind (or re-bind) the hosting page's reach cell — called by the attached
164    /// [`NavigatorWidget`] from `build` and every `rebuild` with whatever
165    /// [`ambient_page_reach`] says at that point, so the binding follows the
166    /// navigator if its subtree ever moves between pages.
167    ///
168    /// Deliberately not public: reach is derived by the navigator hosting this
169    /// one, never declared by an app.
170    pub(super) fn bind_host_reach(&self, reach: Option<Rc<Cell<bool>>>) {
171        *self.host_reach.borrow_mut() = reach;
172    }
173
174    /// Whether the page hosting this navigator is input-routed right now — the
175    /// **R23 back-reach gate**, read live (see
176    /// [`host_reach`](Self::host_reach)). `true` for a top-level navigator and
177    /// before a widget attaches.
178    fn host_reachable(&self) -> bool {
179        host_reachable(self.host_reach.borrow().as_ref())
180    }
181
182    /// Whether a live [`NavigatorWidget`] currently renders this controller's
183    /// stack — i.e. whether this navigator is in the retained tree *right now*.
184    ///
185    /// `false` before the first `build` and again after the widget's `teardown`
186    /// (a screen with its own nested navigator, popped). Unlike
187    /// [`depth`](Self::depth)/[`back_interest`](Self::back_interest) this is not
188    /// an advisory snapshot of the stack: it is exact at every point after the
189    /// widget's `build` began, because `build`/`teardown` write it directly.
190    ///
191    /// The facade's back arbitration is the intended consumer: a registered
192    /// controller that is still mounted is still part of the tree, so it must
193    /// keep its place in the arbitration list even on a pass in which it did not
194    /// re-wire; one that is no longer mounted can be released.
195    pub fn is_mounted(&self) -> bool {
196        self.mounted.get() > 0
197    }
198
199    /// Record that a [`NavigatorWidget`] attached to this controller. Called at
200    /// the very top of [`NavigatorView::build`], *before* the root page builder
201    /// runs: that builder may itself wire a nested navigator, and the facade's
202    /// back arbitration must already see this navigator as mounted by then.
203    pub(super) fn mount(&self) {
204        self.mounted.set(self.mounted.get() + 1);
205    }
206
207    // No `unmount` method here deliberately: unmounting always goes through
208    // the widget-owned `mounted` cell (`unmount_cell`, below), never back
209    // through a `NavigatorController` reference — see the `mounted` field
210    // doc's structural-pairing note and `NavigatorView::rebuild`'s
211    // controller-swap handling.
212
213    /// This controller's [`NavigatorId`] — stable across clones, distinct per
214    /// independently constructed controller. See [`NavigatorId`] for the
215    /// liveness caveat.
216    pub fn id(&self) -> NavigatorId {
217        NavigatorId(Rc::as_ptr(&self.ops) as *const u8 as usize)
218    }
219
220    /// The current page-stack depth of the navigator this controller drives, as
221    /// last published by that navigator's `build`/`rebuild`, or `0` if no
222    /// navigator is attached yet.
223    ///
224    /// **Advisory**: this reflects the depth at the last rebuild, so a query
225    /// racing a same-frame stack change sees the previous value (the
226    /// rebuild-time refresh contract — see [`can_pop`](Self::can_pop) and
227    /// `frust-reactive::back`'s timing note).
228    pub fn depth(&self) -> usize {
229        self.depth.get()
230    }
231
232    /// Whether a [`pop`](Self::pop) would actually remove a page — `true` iff
233    /// the navigator has more than one page ([`depth`](Self::depth)` > 1`).
234    ///
235    /// **Advisory**, for exactly the Android back contract: the
236    /// facade's back handler reads this to decide whether a back press pops or
237    /// bubbles to the platform, and publishes it as
238    /// `frust-reactive::set_handles_back`. The authoritative guard stays the
239    /// widget's own `len > 1` check in [`apply_ops`](NavigatorWidget) — a pop at
240    /// the root remains a safe no-op even if this raced stale, so a
241    /// mis-predicted root-level back never removes the last page.
242    pub fn can_pop(&self) -> bool {
243        self.depth.get() > 1
244    }
245
246    /// Whether the navigator claims the next back press ahead-of-time
247    /// (predictive-back parity) — `true` iff the stack is poppable
248    /// **or** the top page declares a non-[`Pop`](BackPolicy::Pop) policy (a
249    /// dismissable/veto overlay). The facade's back handler reads this
250    /// (in preference to [`can_pop`](Self::can_pop)) to compute the shell's
251    /// `handles_back`, so a dismissable overlay at the root still consumes back
252    /// rather than exiting the app.
253    ///
254    /// # Nested navigators: reach follows input routing (R23)
255    ///
256    /// A navigator hosted on a page its own host navigator routes **no input**
257    /// to (a covered page, or the page under a transparent overlay) reports
258    /// `false` here regardless of its own stack: back arbitration reaches
259    /// exactly as far as input does, so a press can never pop an off-screen
260    /// stack while the visible page stays put. Unconditional `true` for the
261    /// reach term at the top level, so a single-navigator app is unaffected.
262    ///
263    /// **Advisory**, published at the last rebuild like [`depth`](Self::depth) —
264    /// a query racing a same-frame stack change sees the previous value; the
265    /// navigator's own [`request_back`](Self::request_back) routing stays
266    /// authoritative regardless (see `frust-reactive::back`'s timing note).
267    pub fn back_interest(&self) -> bool {
268        // The published cell answers for this navigator's own stack; the reach
269        // gate is ANDed HERE (live) rather than baked into the cell, because the
270        // hosting page can stop being input-routed on a pass in which this
271        // navigator never rebuilds — see `host_reach`.
272        self.back_interest.get() && self.host_reachable()
273    }
274
275    /// The navigator's current [`TransitionState`] — the observation seam chrome
276    /// *outside* the navigator subtree (an app bar, a tab bar, a progress
277    /// indicator) drives its own motion from. [`TransitionState::default`] (an
278    /// at-rest depth-0 snapshot) until a navigator attaches.
279    ///
280    /// Published on the **controller**, not the widget, deliberately: chrome that
281    /// wants to match page motion is a *sibling* of the navigator, not a
282    /// descendant, so it can never reach the widget — but it can hold a
283    /// controller clone, exactly as it already does to `push`.
284    ///
285    /// # The timing contract
286    ///
287    /// The progress driver advances in exactly one place — `paint`, off
288    /// `PaintCtx::frame_time` (the No-`Instant::now()` rule in
289    /// `docs/REVIEW_FOCUS.md` forbids any other clock in `frust-widgets`).
290    /// Therefore:
291    ///
292    /// - **A read during your own `Widget::paint`, from a widget painted *after*
293    ///   the navigator, is exact for the current frame.** In a root
294    ///   `Stack(vec![navigator_subtree, chrome])`, `StackWidget::paint` walks its
295    ///   children in order, so `chrome` paints second and reads the value the
296    ///   navigator wrote microseconds earlier **in the same frame**. This is the
297    ///   frame-perfect path; it is the one to use for choreography.
298    /// - **A read during `Component::build` (or any `View::build`/`rebuild`) is
299    ///   always exactly one frame stale** for `progress`, because build precedes
300    ///   paint. Fine for "is a transition running?"; wrong for choreography.
301    /// - **The [`active`](TransitionState::active) edges are the exception.**
302    ///   Both `start_transition` and `finalize_transition` publish from a
303    ///   `BuildCtx` pass, so a build-time reader that builds *after* the navigator
304    ///   sees `active` flip on the very frame it happens. Only intermediate
305    ///   `progress` lags.
306    ///
307    /// The navigator already requests a frame for every frame a transition runs,
308    /// so a paint-time observer needs no wake of its own.
309    pub fn transition(&self) -> TransitionState {
310        self.transition.get()
311    }
312
313    /// The navigator's currently published route-state snapshot — a
314    /// clone, authoritative as of the last publish (see `route_state`'s
315    /// module docs' staleness contract, in particular the interactive-swipe
316    /// window).
317    pub fn route_stack(&self) -> RouteStack {
318        self.route_stack.borrow().clone()
319    }
320
321    /// The route stack's current generation — an O(1) change gate equivalent
322    /// to `route_stack().generation()` but without cloning the whole
323    /// snapshot.
324    pub fn route_generation(&self) -> u64 {
325        self.route_stack.borrow().generation()
326    }
327
328    /// Push an **opaque** page built by `builder` on top of the stack, using the
329    /// navigator's default transition (instant unless the navigator sets one).
330    pub fn push(&self, builder: impl Fn() -> AnyView<State> + 'static) {
331        self.push_impl(builder, true, None, None);
332    }
333
334    /// Push an **opaque** page with an explicit [`TransitionSpec`], overriding the
335    /// navigator's default for this push only. The spec is stored on the pushed
336    /// page and *reversed* when it is later popped.
337    pub fn push_with(
338        &self,
339        builder: impl Fn() -> AnyView<State> + 'static,
340        transition: TransitionSpec,
341    ) {
342        self.push_impl(builder, true, None, Some(transition));
343    }
344
345    /// Push a **transparent** page (e.g. a dialog/overlay) — the page below it
346    /// stays visible and painted (see [module docs](self)'s paint culling).
347    pub fn push_transparent(&self, builder: impl Fn() -> AnyView<State> + 'static) {
348        self.push_impl(builder, false, None, None);
349    }
350
351    /// Push an opaque page and register `on_result`, invoked with `&mut State`
352    /// when *this* page is later popped (carrying the pop's [`PopResult`]).
353    ///
354    /// The callback is delivered at the start of the [`NavigatorWidget::event`]
355    /// pass after the pop's rebuild — the first point after the pop where the
356    /// erased app state is in scope (a rebuild carries only a `BuildCtx`).
357    pub fn push_for_result(
358        &self,
359        builder: impl Fn() -> AnyView<State> + 'static,
360        on_result: impl Fn(&mut State, PopResult) + 'static,
361    ) {
362        self.push_impl(builder, true, Some(Rc::new(on_result)), None);
363    }
364
365    /// Push a **transparent** page (e.g. a dialog/bottom sheet) with an explicit
366    /// [`TransitionSpec`] (e.g. [`PageTransition::M3FadeThrough`] for a dialog,
367    /// [`PageTransition::SlideUp`] for a bottom sheet — a design system's own
368    /// dialog/sheet helpers are the shipped callers),
369    /// and register `on_result`, invoked with `&mut State` when *this* page is
370    /// later popped (carrying the pop's [`PopResult`]) — the modal-with-a-result
371    /// combination [`push_transparent`](Self::push_transparent) and
372    /// [`push_for_result`](Self::push_for_result) each cover only half of.
373    ///
374    /// ```ignore
375    /// // A confirm dialog that reports whether the user confirmed:
376    /// controller.push_transparent_for_result(
377    ///     || dialog_view(),
378    ///     TransitionSpec::duration(PageTransition::M3FadeThrough),
379    ///     |state: &mut State, result: PopResult| {
380    ///         state.confirmed = result.take::<bool>().unwrap_or(false);
381    ///     },
382    /// );
383    /// ```
384    ///
385    /// The callback is delivered the same way [`push_for_result`](Self::push_for_result)'s
386    /// is: at the start of the [`NavigatorWidget::event`] pass after the pop's
387    /// rebuild.
388    pub fn push_transparent_for_result(
389        &self,
390        builder: impl Fn() -> AnyView<State> + 'static,
391        transition: TransitionSpec,
392        on_result: impl Fn(&mut State, PopResult) + 'static,
393    ) {
394        self.push_impl(builder, false, Some(Rc::new(on_result)), Some(transition));
395    }
396
397    /// Push a page with an explicit [`PushOptions`] — the full-control variant
398    /// carrying a back-press [`BackPolicy`] (and, for a
399    /// [`DismissAnimated`](BackPolicy::DismissAnimated) overlay, its dismiss
400    /// signal) alongside opacity/transition/result. The dismissable-overlay
401    /// helpers push through this; every other `push*` method
402    /// pushes with [`BackPolicy::Pop`].
403    pub fn push_with_options(
404        &self,
405        builder: impl Fn() -> AnyView<State> + 'static,
406        options: PushOptions<State>,
407    ) {
408        self.enqueue(NavOp::Push {
409            builder: Rc::new(builder),
410            opaque: options.opaque,
411            on_result: options.on_result,
412            transition: options.transition,
413            back: options.back,
414            dismiss_signal: options.dismiss_signal,
415            on_visibility: options.on_visibility,
416            route: options.route,
417            pop_swipe: options.pop_swipe,
418        });
419    }
420
421    /// Shared push-op construction every `push*` method above funnels through —
422    /// the five public variants differ only in which of `opaque`/`on_result`/
423    /// `transition` they fix vs. expose. All push with [`BackPolicy::Pop`] and
424    /// no dismiss signal, and no visibility observer; a page wanting any of those
425    /// uses [`push_with_options`](Self::push_with_options).
426    fn push_impl(
427        &self,
428        builder: impl Fn() -> AnyView<State> + 'static,
429        opaque: bool,
430        on_result: Option<ResultCallback<State>>,
431        transition: Option<TransitionSpec>,
432    ) {
433        self.enqueue(NavOp::Push {
434            builder: Rc::new(builder),
435            opaque,
436            on_result,
437            transition,
438            back: BackPolicy::Pop,
439            dismiss_signal: None,
440            on_visibility: None,
441            route: None,
442            pop_swipe: None,
443        });
444    }
445
446    /// Pop the top page with no result payload (a plain back-navigation). A pop
447    /// of the last/root page is ignored (a navigator always keeps one page).
448    pub fn pop(&self) {
449        self.enqueue(NavOp::Pop {
450            result: PopResult::empty(),
451        });
452    }
453
454    /// Pop the top page, handing `result` to its pusher-registered
455    /// [`push_for_result`](Self::push_for_result) callback.
456    pub fn pop_with_result(&self, result: PopResult) {
457        self.enqueue(NavOp::Pop { result });
458    }
459
460    /// Route a back press through the top page's [`BackPolicy`] (the
461    /// entry the facade's back handler drives instead of a bare
462    /// [`pop`](Self::pop)):
463    ///
464    /// - [`Pop`](BackPolicy::Pop) → a normal pop (transitions preserved; a safe
465    ///   no-op at the root);
466    /// - [`DismissAnimated`](BackPolicy::DismissAnimated) → fires the top page's
467    ///   dismiss signal (stack unchanged; the overlay animates its own exit and
468    ///   pops itself), see [`BackPolicy`]'s observation seam;
469    /// - [`Veto`](BackPolicy::Veto) → the press is consumed but nothing happens.
470    ///
471    /// Recorded like every other op and applied at the next rebuild (never
472    /// self-mutating mid-event). Whether this call *would* claim the press is
473    /// [`back_interest`](Self::back_interest).
474    pub fn request_back(&self) {
475        self.enqueue(NavOp::RequestBack);
476    }
477
478    /// Replace the top page in place with an opaque page built by `builder`,
479    /// using the navigator's default transition.
480    pub fn replace(&self, builder: impl Fn() -> AnyView<State> + 'static) {
481        self.enqueue(NavOp::Replace {
482            builder: Rc::new(builder),
483            opaque: true,
484            transition: None,
485            route: None,
486        });
487    }
488
489    /// Replace the top page with an explicit [`TransitionSpec`], overriding the
490    /// navigator's default for this replace only.
491    pub fn replace_with(
492        &self,
493        builder: impl Fn() -> AnyView<State> + 'static,
494        transition: TransitionSpec,
495    ) {
496        self.enqueue(NavOp::Replace {
497            builder: Rc::new(builder),
498            opaque: true,
499            transition: Some(transition),
500            route: None,
501        });
502    }
503
504    /// Replace the top page with an explicit [`ReplaceOptions`] — the
505    /// full-control variant carrying a route identity alongside opacity
506    /// and transition. [`Router::go`](super::router::Router::go)/
507    /// [`Router::replace`](super::router::Router::replace) push through this.
508    pub fn replace_with_options(
509        &self,
510        builder: impl Fn() -> AnyView<State> + 'static,
511        options: ReplaceOptions,
512    ) {
513        self.enqueue(NavOp::Replace {
514            builder: Rc::new(builder),
515            opaque: options.opaque,
516            transition: options.transition,
517            route: options.route,
518        });
519    }
520
521    /// Record `op`, then raise [`frust_core::mark_pending_result_flush`] so the
522    /// next tick's mobile frame gate `Run`s and reaches the rebuild that drains
523    /// it — the same guarantee [`finalize_transition`](NavigatorWidget::finalize_transition)
524    /// already gives a pop-result callback, reused here for a different reason:
525    /// this flag is not just "a callback needs `&mut State`", it is the shell's
526    /// one thread-affine "something is owed, run the next frame regardless of
527    /// what else is dirty" side channel, and a queued op recorded from outside
528    /// any input/signal path (a `spawn_local` continuation, e.g.) needs exactly
529    /// that with no callback involved at all. Without it a page could mount and
530    /// sit unpainted until whatever input happened to arrive next forced a
531    /// frame — see the [module docs](self).
532    ///
533    /// Unconditional, on every op, deliberately, even though the shared flag's
534    /// other reader (`RenderRoot::rebuild`'s pending-flush convergence loop)
535    /// cannot tell "just force a run" apart from "a callback is genuinely
536    /// owed": the frame that applies a queued op also pays one extra, empty
537    /// build + view-diff pass it did not strictly need. That pass is
538    /// bounded (never more than one here, since nothing re-raises the flag for
539    /// a plain structural op) and lands only on the frame a nav op was actually
540    /// queued, not on every frame — a cost this crate's authoring toolkit
541    /// already treats as affordable (rebuilds are cheap by construction) and a
542    /// small, known price next to the bug it replaces: a page mounted with zero
543    /// frames painted for 15+ seconds.
544    fn enqueue(&self, op: NavOp<State>) {
545        self.ops.borrow_mut().push(op);
546        frust_core::mark_pending_result_flush();
547    }
548
549    /// Take the queued ops (leaving the queue empty). Called by
550    /// [`NavigatorView::rebuild`]/`build`.
551    pub(super) fn drain(&self) -> Vec<NavOp<State>> {
552        std::mem::take(&mut *self.ops.borrow_mut())
553    }
554}