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 /// Push `self` as the current scope, run `f`, pop back.
63 /// Reactive primitives (`signal()`, `effect()`, `computed()`,
64 /// view elements created via `render!`) allocated inside `f`
65 /// will belong to this owner.
66 pub fn with<R>(self, f: impl FnOnce() -> R) -> R {
67 with_runtime(|rt| rt.owner_stack.push(self));
68 let result = f();
69 with_runtime(|rt| {
70 let popped = rt.owner_stack.pop();
71 debug_assert_eq!(
72 popped,
73 Some(self),
74 "Owner::with: stack imbalance — owner pop didn't match push"
75 );
76 });
77 result
78 }
79
80 /// Dispose `self`, freeing all its descendants, nodes, and
81 /// running its cleanup callbacks.
82 ///
83 /// Recursive — disposes children first, then this owner. Safe
84 /// to call even if the owner has already been disposed (no-op).
85 pub fn dispose(self) {
86 // Step 1: collect what needs cleaning. We pull data out of
87 // the runtime in a short borrow rather than holding it
88 // through the recursion, because each level may itself need
89 // to mutate the runtime (running cleanup callbacks does not,
90 // but symmetrically we keep the pattern simple by avoiding
91 // nested borrows).
92 let children;
93 let nodes;
94 let cleanups;
95 let parent;
96 let mount_fn;
97 let elements;
98 {
99 let removed = with_runtime(|rt| rt.owners.remove(self));
100 let Some(o) = removed else { return };
101 children = o.children;
102 nodes = o.nodes;
103 cleanups = o.cleanups;
104 parent = o.parent;
105 mount_fn = o.mount_fn;
106 elements = o.elements;
107 }
108
109 // Step 1b: if this was a component owner, scrub the hot-
110 // reload registry so the fn pointer doesn't list a freed
111 // slot. Without this, A6's `owners_for_fn` would return a
112 // dangling Owner and remount logic would fault.
113 if let Some(fp) = mount_fn {
114 with_runtime(|rt| {
115 if let Some(list) = rt.component_owners.get_mut(&fp) {
116 list.retain(|o| *o != self);
117 if list.is_empty() {
118 rt.component_owners.remove(&fp);
119 }
120 }
121 });
122
123 // Step 1c: also clean up any remountable MountSite whose
124 // owner is this one. Without this scrub, cascading
125 // disposal (e.g. parent component re-mounts and discards
126 // its sub-tree) leaves orphan MountSites behind, and
127 // the next `remount_components_for` call processes
128 // them — operating on freed parent / body_root handles,
129 // with visible corruption (issue #17 follow-up).
130 //
131 // `site.owner` is `None` *during* a remount (the
132 // takes-then-reinstalls window in `remount_one`), so
133 // this scan won't accidentally evict the site that's
134 // mid-flight. It only matches MountSites whose
135 // component owner is the one actually being disposed.
136 with_runtime(|rt| {
137 let stale: Vec<super::component::MountId> = rt
138 .mount_sites
139 .iter()
140 .filter_map(|(id, site)| {
141 if site.owner == Some(self) {
142 Some(*id)
143 } else {
144 None
145 }
146 })
147 .collect();
148 for id in stale {
149 rt.mount_sites.remove(&id);
150 if let Some(list) = rt.fn_ptr_mounts.get_mut(&fp) {
151 list.retain(|m| *m != id);
152 if list.is_empty() {
153 rt.fn_ptr_mounts.remove(&fp);
154 }
155 }
156 }
157 });
158 }
159
160 // Step 2: detach from parent's children list.
161 if let Some(p) = parent {
162 with_runtime(|rt| {
163 if let Some(parent_scope) = rt.owners.get_mut(p) {
164 parent_scope.children.retain(|&c| c != self);
165 }
166 });
167 }
168
169 // Step 3: dispose descendants (post-order — bottom up).
170 for child in children {
171 child.dispose();
172 }
173
174 // Step 4: free every node this owner allocated. For effects
175 // / computed values, also detach them from any subscriber
176 // list they were on, so other live nodes don't try to
177 // notify a freed slot later.
178 //
179 // Arc-signal back-references (`arc_sources`) get collected
180 // here and unsubscribed below, outside the runtime borrow —
181 // the unsubscribe callees may re-enter the runtime.
182 let arc_unsubscribes: Vec<(Rc<dyn super::runtime::ArcSubscription>, NodeId)> =
183 with_runtime(|rt| {
184 let mut out: Vec<(Rc<dyn super::runtime::ArcSubscription>, NodeId)> = Vec::new();
185 for node_id in &nodes {
186 let Some(node) = rt.nodes.remove(*node_id) else {
187 continue;
188 };
189 // Remove ourselves from every source's subscriber list.
190 for source in node.sources {
191 if let Some(src_node) = rt.nodes.get_mut(source) {
192 src_node.subscribers.remove(node_id);
193 }
194 }
195 // Remove ourselves from every subscriber's source list —
196 // a signal we owned may have been read by an outer effect.
197 for sub in node.subscribers {
198 if let Some(sub_node) = rt.nodes.get_mut(sub) {
199 sub_node.sources.remove(node_id);
200 }
201 }
202 // Collect arc-signal back-refs so we can call
203 // `unsubscribe` outside the runtime borrow.
204 for arc_src in node.arc_sources {
205 out.push((arc_src, *node_id));
206 }
207 }
208 // Strip these nodes from the pending and deferred queues
209 // if any were scheduled — otherwise a later flush /
210 // resume would try to re-run a freed slot.
211 rt.pending.retain(|n| !nodes.contains(n));
212 rt.deferred.retain(|n| !nodes.contains(n));
213 out
214 });
215
216 // Tell every Arc-backed signal that one of our disposed
217 // nodes used to be on its subscriber list. The signal itself
218 // stays alive (Arc refcount), but pruning here keeps its
219 // list bounded so a long-lived signal doesn't accumulate
220 // dead `NodeId`s from every transient subscriber that came
221 // and went.
222 for (arc_src, subscriber) in arc_unsubscribes {
223 arc_src.unsubscribe(subscriber);
224 }
225
226 // Step 5: release every element handle the disposed owner
227 // created. We do this AFTER recursing into children so that
228 // bottom-up disposal order matches what the renderer
229 // expects (a child element's release before its parent's
230 // is fine; the bridge only complains if a parent is missing
231 // when a child reaches up). Done with the runtime borrow
232 // released so a future renderer that wants to call back
233 // into the reactive system (e.g. to notify "element
234 // released") can do so.
235 for handle in elements {
236 crate::view::release_element(handle);
237 }
238
239 // Step 6: run cleanups in LIFO order, with no runtime
240 // borrow held (cleanups may legitimately touch other parts
241 // of the runtime).
242 for cleanup in cleanups.into_iter().rev() {
243 cleanup();
244 }
245 }
246
247 /// Pause `self` (and its descendants): effects and computeds
248 /// whose scope is the paused subtree skip flush. Their
249 /// scheduled re-runs land on the runtime's `deferred` list
250 /// until [`Owner::resume`] drains them back.
251 ///
252 /// Idempotent — pausing an already-paused owner is a no-op.
253 /// The cascade walks the children tree breadth-first; new
254 /// descendants created while paused inherit the flag via
255 /// [`Owner::new`].
256 ///
257 /// Used by `StackLayout` to freeze back-stack entries that are
258 /// mounted-but-off-screen, matching iOS
259 /// `UINavigationController` / Android Fragment back-stack
260 /// semantics: state survives but no CPU is spent on
261 /// signal-driven re-renders behind the top route.
262 pub fn pause(self) {
263 with_runtime(|rt| {
264 let mut stack = vec![self];
265 while let Some(id) = stack.pop() {
266 let Some(o) = rt.owners.get_mut(id) else {
267 continue;
268 };
269 if o.paused {
270 continue;
271 }
272 o.paused = true;
273 stack.extend(o.children.iter().copied());
274 }
275 });
276 }
277
278 /// Resume `self` (and its descendants): clear the paused flag
279 /// and move any of its deferred effects back onto the pending
280 /// queue so they fire on the next flush.
281 ///
282 /// Idempotent. Iterates [`super::runtime::ReactiveRuntime::deferred`]
283 /// and re-queues every node whose owner is no longer paused —
284 /// including descendants resumed by this cascade, and any
285 /// deferred node whose owner happens to have been unpaused by
286 /// an earlier call.
287 pub fn resume(self) {
288 let any_resumed = with_runtime(|rt| {
289 let mut stack = vec![self];
290 let mut any = false;
291 while let Some(id) = stack.pop() {
292 let Some(o) = rt.owners.get_mut(id) else {
293 continue;
294 };
295 if !o.paused {
296 continue;
297 }
298 o.paused = false;
299 any = true;
300 stack.extend(o.children.iter().copied());
301 }
302 if !any {
303 return false;
304 }
305 // Drain deferred → pending for every node whose owner
306 // is no longer paused. Stale entries (node disposed
307 // under a paused owner) are dropped here.
308 let deferred = std::mem::take(&mut rt.deferred);
309 for node in deferred {
310 let still_paused = rt
311 .nodes
312 .get(node)
313 .and_then(|n| rt.owners.get(n.owner))
314 .map(|o| o.paused);
315 match still_paused {
316 Some(false) => {
317 if !rt.pending.contains(&node) {
318 rt.pending.push(node);
319 }
320 }
321 Some(true) => rt.deferred.push(node),
322 None => {} // node or owner is gone; drop silently
323 }
324 }
325 true
326 });
327 if any_resumed {
328 crate::host_wake::wake_runtime();
329 }
330 }
331
332 /// Whether `self` is currently paused. Mainly for tests;
333 /// production code should drive pause / resume from the
334 /// lifecycle layer and not branch on the flag directly.
335 pub fn is_paused(self) -> bool {
336 with_runtime(|rt| rt.owners.get(self).map(|o| o.paused).unwrap_or(false))
337 }
338}
339
340/// Register a callback to run when the current owner is disposed.
341/// Calls accumulate in LIFO order, mirroring Solid / Leptos
342/// `onCleanup` semantics.
343///
344/// No-op (with a warning in `debug`) if there is no current owner.
345///
346/// Kept as a free function (not a method on [`Owner`]) because it
347/// operates on whatever owner happens to be at the top of the
348/// runtime's owner stack — the caller can't sensibly name it.
349pub fn on_cleanup(f: impl FnOnce() + 'static) {
350 let registered = with_runtime(|rt| {
351 let Some(owner_id) = rt.current_owner() else {
352 return false;
353 };
354 if let Some(scope) = rt.owners.get_mut(owner_id) {
355 scope.cleanups.push(Box::new(f));
356 return true;
357 }
358 false
359 });
360 if !registered {
361 debug_assert!(
362 false,
363 "on_cleanup called outside any owner — registration ignored"
364 );
365 }
366}