whisker_runtime/reactive/owner.rs
1//! Owner / scope API surface.
2//!
3//! [`Owner`] is the public-facing handle for a reactive scope —
4//! the lifetime unit that ties together signals, effects, computed
5//! values, view element handles, and cleanup callbacks. Disposing
6//! an `Owner` cascades into its children, frees every node it
7//! allocated, releases the element handles it owned, and runs its
8//! cleanup callbacks in LIFO order.
9//!
10//! ## When to reach for these methods
11//!
12//! - Application code: **almost never**. `#[component]`,
13//! `provide_context`, `on_cleanup` etc. set up and tear down
14//! owners for you automatically.
15//! - Framework extension code (custom control-flow primitives, a
16//! router, a custom list virtualizer): when you need to mount
17//! sub-trees whose lifetime is shorter than the surrounding
18//! component — that's where `Owner::new` / `owner.with` /
19//! `owner.dispose` come in.
20//! - Tests: hand-driving owner lifecycle is convenient for
21//! reactive unit tests.
22//!
23//! See the crate-level docs for the conceptual model.
24//!
25//! The underlying [`Owner`] type is a `Copy` slotmap key
26//! defined in [`super::runtime`]; the methods on this page are
27//! attached to that type via an `impl` block.
28
29use std::rc::Rc;
30
31use super::runtime::{NodeId, Owner, Scope};
32use super::with_runtime;
33
34impl Owner {
35 /// Create a new owner. If `parent` is `None` the current
36 /// top-of-stack owner is used (or the owner becomes a root if
37 /// the stack is empty).
38 ///
39 /// The new owner inherits its parent's `paused` flag — so a
40 /// sub-component mounted while its containing route is
41 /// suspended starts paused, and its effects won't fire until
42 /// the route resumes.
43 pub fn new(parent: Option<Owner>) -> Owner {
44 with_runtime(|rt| {
45 let parent = parent.or_else(|| rt.current_owner());
46 let parent_paused = parent
47 .and_then(|p| rt.owners.get(p))
48 .map(|o| o.paused)
49 .unwrap_or(false);
50 let mut scope = Scope::new(parent);
51 scope.paused = parent_paused;
52 let id = rt.owners.insert(scope);
53 if let Some(p) = parent {
54 if let Some(parent_scope) = rt.owners.get_mut(p) {
55 parent_scope.children.push(id);
56 }
57 }
58 id
59 })
60 }
61
62 /// Create a parentless **root** owner, ignoring whatever owner is
63 /// currently on the stack.
64 ///
65 /// Unlike [`Owner::new(None)`](Owner::new) — which adopts the
66 /// current top-of-stack owner as parent — this always produces a
67 /// detached root. Use it for **process-global singletons** whose
68 /// lifetime must not be tied to the (possibly short-lived) owner
69 /// that happens to be active when the singleton is first touched.
70 ///
71 /// The canonical case is a module that lazily mints an
72 /// arena-backed handle on first access (e.g.
73 /// `whisker-safe-area`): if that first access lands inside a
74 /// per-route / per-component owner, minting under `new(None)` would
75 /// free the handle when that scope disposes, and a later read would
76 /// hit a disposed node. Minting under a `detached_root()` (then
77 /// never disposing it) keeps the handle alive for the whole
78 /// process — the intended semantics for a singleton.
79 ///
80 /// The returned owner is never auto-disposed; the caller is
81 /// expected to leak it (i.e. drop the handle without calling
82 /// [`dispose`](Owner::dispose)) for genuine process-lifetime data.
83 pub fn detached_root() -> Owner {
84 with_runtime(|rt| rt.owners.insert(Scope::new(None)))
85 }
86
87 /// Push `self` as the current scope, run `f`, pop back.
88 /// Reactive primitives (`signal()`, `effect()`, `computed()`,
89 /// view elements created via `render!`) allocated inside `f`
90 /// will belong to this owner.
91 pub fn with<R>(self, f: impl FnOnce() -> R) -> R {
92 with_runtime(|rt| rt.owner_stack.push(self));
93 let result = f();
94 with_runtime(|rt| {
95 let popped = rt.owner_stack.pop();
96 debug_assert_eq!(
97 popped,
98 Some(self),
99 "Owner::with: stack imbalance — owner pop didn't match push"
100 );
101 });
102 result
103 }
104
105 /// Dispose `self`, freeing all its descendants, nodes, and
106 /// running its cleanup callbacks.
107 ///
108 /// Recursive — disposes children first, then this owner. Safe
109 /// to call even if the owner has already been disposed (no-op).
110 pub fn dispose(self) {
111 // Step 1: collect what needs cleaning. We pull data out of
112 // the runtime in a short borrow rather than holding it
113 // through the recursion, because each level may itself need
114 // to mutate the runtime (running cleanup callbacks does not,
115 // but symmetrically we keep the pattern simple by avoiding
116 // nested borrows).
117 let children;
118 let nodes;
119 let cleanups;
120 let parent;
121 let mount_fn;
122 let elements;
123 {
124 let removed = with_runtime(|rt| rt.owners.remove(self));
125 let Some(o) = removed else { return };
126 children = o.children;
127 nodes = o.nodes;
128 cleanups = o.cleanups;
129 parent = o.parent;
130 mount_fn = o.mount_fn;
131 elements = o.elements;
132 }
133
134 // Step 1b: if this was a component owner, scrub the hot-
135 // reload registry so the fn pointer doesn't list a freed
136 // slot. Without this, A6's `owners_for_fn` would return a
137 // dangling Owner and remount logic would fault.
138 if let Some(fp) = mount_fn {
139 with_runtime(|rt| {
140 if let Some(list) = rt.component_owners.get_mut(&fp) {
141 list.retain(|o| *o != self);
142 if list.is_empty() {
143 rt.component_owners.remove(&fp);
144 }
145 }
146 });
147
148 // Step 1c: also clean up any remountable MountSite whose
149 // owner is this one. Without this scrub, cascading
150 // disposal (e.g. parent component re-mounts and discards
151 // its sub-tree) leaves orphan MountSites behind, and
152 // the next `remount_components_for` call processes
153 // them — operating on freed parent / body_root handles,
154 // with visible corruption (issue #17 follow-up).
155 //
156 // `site.owner` is `None` *during* a remount (the
157 // takes-then-reinstalls window in `remount_one`), so
158 // this scan won't accidentally evict the site that's
159 // mid-flight. It only matches MountSites whose
160 // component owner is the one actually being disposed.
161 with_runtime(|rt| {
162 let stale: Vec<super::component::MountId> = rt
163 .mount_sites
164 .iter()
165 .filter_map(|(id, site)| {
166 if site.owner == Some(self) {
167 Some(*id)
168 } else {
169 None
170 }
171 })
172 .collect();
173 for id in stale {
174 rt.mount_sites.remove(&id);
175 if let Some(list) = rt.fn_ptr_mounts.get_mut(&fp) {
176 list.retain(|m| *m != id);
177 if list.is_empty() {
178 rt.fn_ptr_mounts.remove(&fp);
179 }
180 }
181 }
182 });
183 }
184
185 // Step 2: detach from parent's children list.
186 if let Some(p) = parent {
187 with_runtime(|rt| {
188 if let Some(parent_scope) = rt.owners.get_mut(p) {
189 parent_scope.children.retain(|&c| c != self);
190 }
191 });
192 }
193
194 // Step 3: dispose descendants (post-order — bottom up).
195 for child in children {
196 child.dispose();
197 }
198
199 // Step 4: free every node this owner allocated. For effects
200 // / computed values, also detach them from any subscriber
201 // list they were on, so other live nodes don't try to
202 // notify a freed slot later.
203 //
204 // Arc-signal back-references (`arc_sources`) get collected
205 // here and unsubscribed below, outside the runtime borrow —
206 // the unsubscribe callees may re-enter the runtime.
207 let arc_unsubscribes: Vec<(Rc<dyn super::runtime::ArcSubscription>, NodeId)> =
208 with_runtime(|rt| {
209 let mut out: Vec<(Rc<dyn super::runtime::ArcSubscription>, NodeId)> = Vec::new();
210 for node_id in &nodes {
211 let Some(node) = rt.nodes.remove(*node_id) else {
212 continue;
213 };
214 // Remove ourselves from every source's subscriber list.
215 for source in node.sources {
216 if let Some(src_node) = rt.nodes.get_mut(source) {
217 src_node.subscribers.remove(node_id);
218 }
219 }
220 // Remove ourselves from every subscriber's source list —
221 // a signal we owned may have been read by an outer effect.
222 for sub in node.subscribers {
223 if let Some(sub_node) = rt.nodes.get_mut(sub) {
224 sub_node.sources.remove(node_id);
225 }
226 }
227 // Collect arc-signal back-refs so we can call
228 // `unsubscribe` outside the runtime borrow.
229 for arc_src in node.arc_sources {
230 out.push((arc_src, *node_id));
231 }
232 }
233 // Strip these nodes from the pending and deferred queues
234 // if any were scheduled — otherwise a later flush /
235 // resume would try to re-run a freed slot.
236 rt.pending.retain(|n| !nodes.contains(n));
237 rt.deferred.retain(|n| !nodes.contains(n));
238 out
239 });
240
241 // Tell every Arc-backed signal that one of our disposed
242 // nodes used to be on its subscriber list. The signal itself
243 // stays alive (Arc refcount), but pruning here keeps its
244 // list bounded so a long-lived signal doesn't accumulate
245 // dead `NodeId`s from every transient subscriber that came
246 // and went.
247 for (arc_src, subscriber) in arc_unsubscribes {
248 arc_src.unsubscribe(subscriber);
249 }
250
251 // Step 5: release every element handle the disposed owner
252 // created. We do this AFTER recursing into children so that
253 // bottom-up disposal order matches what the renderer
254 // expects (a child element's release before its parent's
255 // is fine; the bridge only complains if a parent is missing
256 // when a child reaches up). Done with the runtime borrow
257 // released so a future renderer that wants to call back
258 // into the reactive system (e.g. to notify "element
259 // released") can do so.
260 for handle in elements {
261 crate::view::release_element(handle);
262 }
263
264 // Step 6: run cleanups in LIFO order, with no runtime
265 // borrow held (cleanups may legitimately touch other parts
266 // of the runtime).
267 for cleanup in cleanups.into_iter().rev() {
268 cleanup();
269 }
270 }
271
272 /// Pause `self` (and its descendants): effects and computeds
273 /// whose scope is the paused subtree skip flush. Their
274 /// scheduled re-runs land on the runtime's `deferred` list
275 /// until [`Owner::resume`] drains them back.
276 ///
277 /// Idempotent — pausing an already-paused owner is a no-op.
278 /// The cascade walks the children tree breadth-first; new
279 /// descendants created while paused inherit the flag via
280 /// [`Owner::new`].
281 ///
282 /// Used by `StackLayout` to freeze back-stack entries that are
283 /// mounted-but-off-screen, matching iOS
284 /// `UINavigationController` / Android Fragment back-stack
285 /// semantics: state survives but no CPU is spent on
286 /// signal-driven re-renders behind the top route.
287 pub fn pause(self) {
288 with_runtime(|rt| {
289 let mut stack = vec![self];
290 while let Some(id) = stack.pop() {
291 let Some(o) = rt.owners.get_mut(id) else {
292 continue;
293 };
294 if o.paused {
295 continue;
296 }
297 o.paused = true;
298 stack.extend(o.children.iter().copied());
299 }
300 });
301 }
302
303 /// Resume `self` (and its descendants): clear the paused flag
304 /// and move any of its deferred effects back onto the pending
305 /// queue so they fire on the next flush.
306 ///
307 /// Idempotent. Iterates [`super::runtime::ReactiveRuntime::deferred`]
308 /// and re-queues every node whose owner is no longer paused —
309 /// including descendants resumed by this cascade, and any
310 /// deferred node whose owner happens to have been unpaused by
311 /// an earlier call.
312 pub fn resume(self) {
313 let any_resumed = with_runtime(|rt| {
314 let mut stack = vec![self];
315 let mut any = false;
316 while let Some(id) = stack.pop() {
317 let Some(o) = rt.owners.get_mut(id) else {
318 continue;
319 };
320 if !o.paused {
321 continue;
322 }
323 o.paused = false;
324 any = true;
325 stack.extend(o.children.iter().copied());
326 }
327 if !any {
328 return false;
329 }
330 // Drain deferred → pending for every node whose owner
331 // is no longer paused. Stale entries (node disposed
332 // under a paused owner) are dropped here.
333 let deferred = std::mem::take(&mut rt.deferred);
334 for node in deferred {
335 let still_paused = rt
336 .nodes
337 .get(node)
338 .and_then(|n| rt.owners.get(n.owner))
339 .map(|o| o.paused);
340 match still_paused {
341 Some(false) => {
342 if !rt.pending.contains(&node) {
343 rt.pending.push(node);
344 }
345 }
346 Some(true) => rt.deferred.push(node),
347 None => {} // node or owner is gone; drop silently
348 }
349 }
350 true
351 });
352 if any_resumed {
353 crate::host_wake::wake_runtime();
354 }
355 }
356
357 /// Whether `self` is currently paused. Mainly for tests;
358 /// production code should drive pause / resume from the
359 /// lifecycle layer and not branch on the flag directly.
360 pub fn is_paused(self) -> bool {
361 with_runtime(|rt| rt.owners.get(self).map(|o| o.paused).unwrap_or(false))
362 }
363}
364
365/// Register a callback to run when the current owner is disposed.
366/// Calls accumulate in LIFO order, mirroring Solid / Leptos
367/// `onCleanup` semantics.
368///
369/// No-op (with a warning in `debug`) if there is no current owner.
370///
371/// Kept as a free function (not a method on [`Owner`]) because it
372/// operates on whatever owner happens to be at the top of the
373/// runtime's owner stack — the caller can't sensibly name it.
374pub fn on_cleanup(f: impl FnOnce() + 'static) {
375 let registered = with_runtime(|rt| {
376 let Some(owner_id) = rt.current_owner() else {
377 return false;
378 };
379 if let Some(scope) = rt.owners.get_mut(owner_id) {
380 scope.cleanups.push(Box::new(f));
381 return true;
382 }
383 false
384 });
385 if !registered {
386 debug_assert!(
387 false,
388 "on_cleanup called outside any owner — registration ignored"
389 );
390 }
391}