whisker_runtime/reactive/component.rs
1//! Component scoping, lifecycle, and hot-reload owner registry.
2//!
3//! Users normally interact with this module through the
4//! `#[component]` proc-macro, which expands a function definition
5//! into a body that:
6//!
7//! 1. Creates a fresh owner with [`mount_component`].
8//! 2. Runs the user's body inside that owner.
9//! 3. Returns the resulting view, leaving the owner alive (the parent
10//! keeps the handle; disposing the parent will cascade).
11//!
12//! The macro also passes its own fn pointer to
13//! [`register_component`] so the Strategy C hot-reload path (A6) can
14//! map subsecond-patched fn pointers back to live owners.
15//!
16//! Lifecycle hooks:
17//!
18//! - [`on_mount`] — registered against the current owner; fires once
19//! on the next [`flush_mounts`]. The renderer (A3) calls
20//! `flush_mounts` after appending the component's view to its
21//! parent.
22//! - `on_cleanup` lives in `owner.rs` — symmetric LIFO callback that
23//! fires when the owner is disposed.
24
25use std::rc::Rc;
26
27use super::runtime::Owner;
28use super::{untrack, with_runtime};
29use crate::view::Element;
30
31/// Mount a component: create a fresh child owner, register `fn_ptr`
32/// against it for hot reload, run `body` inside that owner, and
33/// return both the owner id and the body's result.
34///
35/// The caller is responsible for keeping the returned `Owner` alive
36/// (e.g. attaching it to the parent component's owner-children list
37/// via the renderer) and for disposing it when the component
38/// unmounts. The owner is already linked as a child of the
39/// current-owner-at-call-time, so calling [`Owner::dispose`] on an
40/// ancestor will cascade.
41pub fn mount_component<R>(fn_ptr: *const (), body: impl FnOnce() -> R) -> (Owner, R) {
42 let owner = Owner::new(None);
43 with_runtime(|rt| {
44 if let Some(o) = rt.owners.get_mut(owner) {
45 o.mount_fn = Some(fn_ptr);
46 }
47 rt.component_owners.entry(fn_ptr).or_default().push(owner);
48 });
49 // Component bodies build a static Element tree; the reactive
50 // dependencies they declare must come from explicit
51 // `effect` / `computed` calls *inside* the body, not from
52 // ambient signal reads contaminating whatever outer reactive
53 // node we happened to be constructed inside (a parent
54 // component's `Show` effect, `StackLayout`'s route mount, etc.).
55 // Clear the tracker around the body call so a direct
56 // `signal.get()` in user code doesn't silently subscribe the
57 // outer node.
58 let result = untrack(|| owner.with(body));
59 (owner, result)
60}
61
62/// Dispose a component owner *and* deregister it from
63/// `component_owners`. Use this instead of plain `Owner::dispose` for
64/// owners created via `mount_component`.
65pub fn unmount_component(owner: Owner) {
66 let fn_ptr = with_runtime(|rt| rt.owners.get(owner).and_then(|o| o.mount_fn));
67 if let Some(fp) = fn_ptr {
68 with_runtime(|rt| {
69 if let Some(list) = rt.component_owners.get_mut(&fp) {
70 list.retain(|o| *o != owner);
71 if list.is_empty() {
72 rt.component_owners.remove(&fp);
73 }
74 }
75 });
76 }
77 owner.dispose();
78}
79
80/// Register `f` as a post-mount callback for the current owner. Fires
81/// once on the next [`flush_mounts`] call (driven by the renderer
82/// after the component's view is appended to its parent).
83///
84/// No-op (with debug-build warning) if there is no current owner.
85pub fn on_mount(f: impl FnOnce() + 'static) {
86 let registered = with_runtime(|rt| {
87 if rt.current_owner().is_none() {
88 return false;
89 }
90 rt.pending_mounts.push(Box::new(f));
91 true
92 });
93 if !registered {
94 super::warn_no_owner("on_mount");
95 }
96}
97
98/// Run all queued on_mount callbacks in registration order. Called by
99/// the renderer (A3) after a batch of component views has been
100/// appended to the tree. Safe to call when the queue is empty
101/// (no-op).
102pub fn flush_mounts() {
103 // Drain the queue under a short borrow so callback bodies (which
104 // may themselves register new on_mount) land in a fresh queue.
105 let queue: Vec<Box<dyn FnOnce()>> = with_runtime(|rt| std::mem::take(&mut rt.pending_mounts));
106 for cb in queue {
107 // `on_mount` callbacks are fire-once side effects that may
108 // read signals to inspect post-mount state but should never
109 // subscribe whatever node happens to be on the call stack
110 // when the queue gets drained. In production `flush_mounts`
111 // runs after `reactive_flush` returns (tracker already
112 // cleared by the scheduler), but other integrations may
113 // call it from inside a reactive scope — wrap each `cb` in
114 // `untrack` so the invariant is enforced by the queue itself.
115 untrack(cb);
116 }
117}
118
119/// Look up the owners currently associated with `fn_ptr`. Used by the
120/// A6 hot-reload path to find which live owners need disposal +
121/// remount when subsecond patches a component function body. Returns
122/// a snapshot — modifying the runtime's `component_owners` after
123/// this call won't affect the returned `Vec`.
124#[doc(hidden)]
125pub fn owners_for_fn(fn_ptr: *const ()) -> Vec<Owner> {
126 with_runtime(|rt| {
127 rt.component_owners
128 .get(&fn_ptr)
129 .cloned()
130 .unwrap_or_default()
131 })
132}
133
134// ===========================================================================
135// True per-component remount — wrapper-less (issue #17 / Y-2 P1)
136// ===========================================================================
137//
138// `mount_component_remountable` runs the user's body inside a fresh
139// owner and **returns the body's root element directly** — no wrapper
140// `view` is inserted between the body and its parent. The Whisker
141// component tree maps 1:1 with the Lynx element tree.
142//
143// To make remount still work without a wrapper as a stable
144// placeholder, we capture each mount's `(parent, previous_sibling)`
145// lazily: `mount_component_remountable` stashes the freshly-created
146// `MountId` + body_root in a thread-local `PENDING_MOUNT` slot,
147// and `view::append_child` (when it sees that body_root being
148// attached) calls back via [`on_component_root_attached`] to
149// populate `MountSite.parent` / `MountSite.anchor`.
150//
151// On a subsecond patch:
152// 1. Look up the MountSite by patched fn_ptr.
153// 2. Detach old body_root from parent (Whisker-side child mirror
154// keeps the position information so we know where to re-insert).
155// 3. Dispose old owner — cascading reactive cleanup, on_cleanup,
156// nested component disposal.
157// 4. Re-invoke body inside a fresh owner → new body_root.
158// 5. Insert new body_root at the same slot (after the same
159// previous-sibling anchor, or at the start if no anchor).
160//
161// Trade-offs / known limitations:
162// - The "previous sibling" anchor must remain alive across remounts.
163// If a sibling-managed component disposed itself between mount
164// and patch, the anchor is stale and remount falls back to
165// inserting at the previous numeric position (best effort).
166// For/Show interactions don't normally cause this because their
167// wrappers are themselves stable elements.
168// - Component-local signal state is lost on remount; context-stored
169// state survives because its owners live above the disposed scope.
170// - Props must implement `Clone` so the body closure can hand the
171// user code fresh owned values on each invocation.
172
173use std::cell::Cell;
174
175thread_local! {
176 /// Set immediately before `mount_component_remountable` returns
177 /// its body_root. Consumed by `view::append_child` on the next
178 /// matching attach. The TLS is single-slot (last-writer-wins):
179 /// nested component mounts handle themselves because the body's
180 /// inner `view::append_child` calls drain the inner pending
181 /// mounts before this function's own value is stashed.
182 static PENDING_MOUNT: Cell<Option<(MountId, Element)>> = const { Cell::new(None) };
183}
184
185/// Stable identifier for a remountable mount site. Generationless on
186/// purpose — entries are removed when the site is torn down, so the
187/// monotonic counter never collides for live entries.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
189pub struct MountId(pub(crate) u64);
190
191/// One live remountable component mount.
192pub(crate) struct MountSite {
193 /// Function pointer of the component fn that produced this mount.
194 /// Used for the patched-fn lookup at hot-reload time.
195 pub fn_ptr: *const (),
196 /// User body closure. `Rc` so the remount path can clone the
197 /// handle out of the runtime borrow before invoking it (the body
198 /// re-enters the runtime via `view::*` / `signal()` / etc., so
199 /// holding the runtime borrow across the call would deadlock).
200 pub body: Rc<dyn Fn() -> Element + 'static>,
201 /// Current owner — `Some` between mounts, `None` during the
202 /// dispose-then-remount window.
203 pub owner: Option<Owner>,
204 /// Element handle the body returned for its outermost element.
205 /// Detached from the parent at the start of each remount, then
206 /// replaced by the new body's root inserted at the same slot.
207 pub body_root: Option<Element>,
208 /// Parent element this component is attached to. `None` until
209 /// `view::append_child` fires for the body_root for the first
210 /// time. `Some(_)` thereafter, kept up to date across remounts.
211 pub parent: Option<Element>,
212 /// Element handle that was the body_root's immediate predecessor
213 /// in `parent`'s child list at attach time. `None` if the body
214 /// was the first child of parent. Stable across remounts unless
215 /// the anchor itself is removed by some other code path.
216 pub anchor: Option<Element>,
217}
218
219/// Called by `view::append_child` after every successful attach.
220/// If there's a pending component mount whose body_root matches the
221/// just-attached `child`, finalise its MountSite by recording the
222/// parent + previous-sibling anchor.
223///
224/// No-op if no mount is pending or the pending body_root doesn't
225/// match — in that case the pending entry is restored so a later
226/// matching attach can still claim it.
227pub fn on_component_root_attached(parent: Element, child: Element) {
228 let pending = PENDING_MOUNT.with(|cell| cell.take());
229 let Some((mount_id, root)) = pending else {
230 return;
231 };
232 if root != child {
233 // The attach was for some other element. Put the pending
234 // entry back so the body_root's eventual `append_child`
235 // can still pick it up.
236 PENDING_MOUNT.with(|cell| cell.set(Some((mount_id, root))));
237 return;
238 }
239 let anchor = crate::view::previous_sibling(parent, child);
240 super::with_runtime(|rt| {
241 if let Some(site) = rt.mount_sites.get_mut(&mount_id) {
242 site.parent = Some(parent);
243 site.anchor = anchor;
244 }
245 });
246}
247
248/// Test/internal: clear the pending-mount slot. Use between
249/// scenarios that share a thread.
250#[doc(hidden)]
251pub fn __reset_pending_mount_for_tests() {
252 PENDING_MOUNT.with(|cell| cell.set(None));
253}
254
255/// Mount a component with full remount support — wrapper-less.
256///
257/// Runs `body` inside a fresh owner and returns the body's root
258/// element directly to the caller. No wrapper element is created,
259/// so the Whisker component tree maps 1:1 with the Lynx element
260/// tree (issue #17).
261///
262/// To make remount work without a stable wrapper handle in the
263/// parent's child list, the function stashes a pending-mount entry
264/// in a thread-local just before returning. The next
265/// [`view::append_child`] call that sees this body_root being
266/// attached finalises the MountSite (recording parent + previous
267/// sibling). The [`on_component_root_attached`] callback handles
268/// that side of the handshake.
269///
270/// On a subsecond patch matching `fn_ptr`, the runtime calls
271/// [`remount_components_for`] which disposes the current owner,
272/// re-invokes `body` in a new owner, removes the old body_root
273/// from its parent, and inserts the new body_root at the same slot
274/// (using the recorded anchor).
275pub fn mount_component_remountable<F>(fn_ptr: *const (), body: F) -> Element
276where
277 F: Fn() -> Element + 'static,
278{
279 let body: Rc<dyn Fn() -> Element + 'static> = Rc::new(body);
280
281 // Initial mount: fresh owner, run body, capture root.
282 let body_for_first = body.clone();
283 let owner = Owner::new(None);
284 with_runtime(|rt| {
285 if let Some(o) = rt.owners.get_mut(owner) {
286 o.mount_fn = Some(fn_ptr);
287 }
288 rt.component_owners.entry(fn_ptr).or_default().push(owner);
289 });
290 // See `mount_component` for the rationale on the `untrack`
291 // bracket. Same invariant applies to the remountable variant.
292 let body_root = untrack(|| owner.with(|| (*body_for_first)()));
293
294 // Register the MountSite with parent / anchor as `None` for now
295 // — the next `view::append_child` that attaches `body_root`
296 // will populate them via `on_component_root_attached`.
297 let mount_id = with_runtime(|rt| {
298 rt.mount_id_counter += 1;
299 let id = MountId(rt.mount_id_counter);
300 rt.mount_sites.insert(
301 id,
302 MountSite {
303 fn_ptr,
304 body,
305 owner: Some(owner),
306 body_root: Some(body_root),
307 parent: None,
308 anchor: None,
309 },
310 );
311 rt.fn_ptr_mounts.entry(fn_ptr).or_default().push(id);
312 id
313 });
314
315 // Hand the (MountId, body_root) pair to the pending slot. The
316 // caller's `view::append_child(parent, body_root)` consumes it
317 // and binds parent + anchor. Any previously-stashed pending
318 // mount that *wasn't* consumed (orphaned — body returned a root
319 // that was never attached) gets dropped here; the orphan's
320 // MountSite stays in the registry without a parent and will
321 // simply be skipped by remount lookups.
322 PENDING_MOUNT.with(|cell| cell.set(Some((mount_id, body_root))));
323
324 body_root
325}
326
327/// Re-mount every remountable site whose `fn_ptr` is in the given
328/// list. Called by the bootstrap's tick callback after a successful
329/// subsecond patch. Internally:
330///
331/// 1. Collect the set of `MountId`s to remount (deduplicated, even
332/// if the patch list contains the same fn pointer multiple times).
333/// 2. For each: detach the previous body root from its wrapper,
334/// dispose the previous owner (cascading reactive cleanup), then
335/// create a fresh owner, re-invoke the body, append the new root
336/// to the same wrapper, and update the site's `owner` / `body_root`.
337///
338/// The wrapper element stays put in the parent's child list across
339/// the whole flow, so the user-visible navigation / scroll position
340/// / sibling order are preserved.
341pub fn remount_components_for(patched_fns: &[*const ()]) {
342 if patched_fns.is_empty() {
343 return;
344 }
345 // Collect candidate mount sites, then filter out any whose
346 // ancestor component is also in this patch batch. When a
347 // parent component's body is patched, remounting it
348 // re-creates the whole subtree from scratch — separately
349 // remounting children would either operate on stale parent
350 // state (if processed first) or no-op (if scrubbed by the
351 // cascading dispose). Both outcomes are wrong; skipping the
352 // descendant entirely is the correct semantics.
353 let patched_set: std::collections::HashSet<*const ()> = patched_fns.iter().copied().collect();
354 let ids: Vec<MountId> = with_runtime(|rt| {
355 let mut candidates: Vec<MountId> = Vec::new();
356 for fp in patched_fns {
357 if let Some(list) = rt.fn_ptr_mounts.get(fp) {
358 for id in list {
359 if !candidates.contains(id) {
360 candidates.push(*id);
361 }
362 }
363 }
364 }
365 candidates
366 .into_iter()
367 .filter(|mount_id| {
368 // Walk the owner chain upward; if any ancestor
369 // owner's mount_fn is in `patched_set`, skip.
370 let site = match rt.mount_sites.get(mount_id) {
371 Some(s) => s,
372 None => return false,
373 };
374 let mut cursor = match site.owner {
375 Some(o) => o,
376 None => return false,
377 };
378 while let Some(parent) = rt.owners.get(cursor).and_then(|o| o.parent) {
379 if let Some(mf) = rt.owners.get(parent).and_then(|o| o.mount_fn) {
380 if patched_set.contains(&mf) {
381 return false;
382 }
383 }
384 cursor = parent;
385 }
386 true
387 })
388 .collect()
389 });
390
391 if ids.is_empty() {
392 return;
393 }
394
395 // ---- Batched remount that preserves sibling order ---------------------
396 //
397 // The naive "one-at-a-time" version (`remount_one` per site) suffers
398 // anchor staleness when sibling components are remounted together:
399 // each site's `anchor` is a sibling's body_root, and once that
400 // sibling has been remounted earlier in the loop, the anchor points
401 // at an element that has already been detached → fallback to
402 // index 0 → siblings clump at the top of the parent in
403 // hash-iteration order, visibly scrambling the layout.
404 //
405 // Instead we do the whole batch as one operation:
406 // 1. Snapshot each unique parent's current child list before
407 // anything mutates.
408 // 2. For every site, dispose old owner + run new body to get the
409 // new body_root. The new body runs against a fresh owner so
410 // reactive state is isolated. None of this touches the parent's
411 // child list.
412 // 3. For each parent, build the desired final child list by
413 // replacing each old body_root with its new body_root, leaving
414 // non-replaced siblings untouched.
415 // 4. Remove every old body_root from the parent, then re-insert
416 // each new body_root at its desired index (ascending order).
417 // 5. Refresh anchors from the post-mutation child list so future
418 // individual remounts also see a coherent state.
419
420 struct RemountInfo {
421 mount_id: MountId,
422 parent: Element,
423 old_body_root: Element,
424 body: Rc<dyn Fn() -> Element + 'static>,
425 fn_ptr: *const (),
426 }
427
428 let infos: Vec<RemountInfo> = with_runtime(|rt| {
429 ids.iter()
430 .filter_map(|mid| {
431 let site = rt.mount_sites.get(mid)?;
432 Some(RemountInfo {
433 mount_id: *mid,
434 parent: site.parent?,
435 old_body_root: site.body_root?,
436 body: site.body.clone(),
437 fn_ptr: site.fn_ptr,
438 })
439 })
440 .collect()
441 });
442
443 if infos.is_empty() {
444 return;
445 }
446
447 // 1. Snapshot each unique parent's child list.
448 let mut parent_snapshot: std::collections::HashMap<Element, Vec<Element>> =
449 std::collections::HashMap::new();
450 for info in &infos {
451 parent_snapshot
452 .entry(info.parent)
453 .or_insert_with(|| crate::view::children_of(info.parent));
454 }
455
456 // 2. Detach every old body_root from its parent *before* any
457 // dispose runs. Element handles get invalidated by
458 // `Owner::dispose` (renderer slot becomes `None`), so once
459 // disposed, subsequent `remove_child` calls would silently
460 // no-op against Lynx — visible as "stale subtree still on
461 // screen" after hot reload. Doing the remove first keeps the
462 // handle live.
463 let mut by_parent: std::collections::HashMap<Element, Vec<(Element, Option<Element>)>> =
464 std::collections::HashMap::new();
465 for info in &infos {
466 crate::view::remove_child(info.parent, info.old_body_root);
467 by_parent
468 .entry(info.parent)
469 .or_default()
470 .push((info.old_body_root, None));
471 }
472
473 // 3. Dispose old owners + run new bodies, collecting (mount_id,
474 // parent, old_root, new_root, new_owner).
475 let mut results: Vec<(MountId, Element, Element, Element, Owner)> =
476 Vec::with_capacity(infos.len());
477 for info in infos {
478 let old_owner = with_runtime(|rt| {
479 let site = rt.mount_sites.get_mut(&info.mount_id)?;
480 site.body_root.take();
481 site.owner.take()
482 });
483 if let Some(o) = old_owner {
484 o.dispose();
485 }
486
487 let new_owner = Owner::new(None);
488 with_runtime(|rt| {
489 if let Some(o) = rt.owners.get_mut(new_owner) {
490 o.mount_fn = Some(info.fn_ptr);
491 }
492 rt.component_owners
493 .entry(info.fn_ptr)
494 .or_default()
495 .push(new_owner);
496 });
497 // `untrack` so the remounted body's signal reads register
498 // against its own nested `effect`/`computed`s, not against
499 // whatever scheduler context happens to be active when
500 // `tick_callback` calls into us.
501 let new_body_root = untrack(|| new_owner.with(|| (*info.body)()));
502 // The body's `mount_component_remountable` calls leave a
503 // PENDING_MOUNT entry behind; we drain it here because the
504 // batched path attaches the new root via `insert_child_at`
505 // directly, not via the caller's `append_child`.
506 PENDING_MOUNT.with(|cell| cell.set(None));
507
508 // Backfill the new_root into by_parent so step 4 can map
509 // old → new when computing the desired final order.
510 if let Some(list) = by_parent.get_mut(&info.parent) {
511 if let Some(entry) = list
512 .iter_mut()
513 .find(|(o, n)| *o == info.old_body_root && n.is_none())
514 {
515 entry.1 = Some(new_body_root);
516 }
517 }
518
519 results.push((
520 info.mount_id,
521 info.parent,
522 info.old_body_root,
523 new_body_root,
524 new_owner,
525 ));
526 }
527
528 // 4. Per-parent: compute desired final order, insert new roots
529 // at their target indices. (Removes already happened in
530 // step 2 — the live-handle requirement.)
531 for (parent, pairs) in &by_parent {
532 let snapshot = parent_snapshot.get(parent).cloned().unwrap_or_default();
533 let old_to_new: std::collections::HashMap<Element, Element> = pairs
534 .iter()
535 .filter_map(|(o, n)| n.map(|new_root| (*o, new_root)))
536 .collect();
537
538 // Desired final list = snapshot with each old replaced by its
539 // matching new (leaving non-replaced siblings untouched).
540 let desired: Vec<Element> = snapshot
541 .iter()
542 .map(|c| old_to_new.get(c).copied().unwrap_or(*c))
543 .collect();
544
545 // Insert new body_roots at their desired indices in ascending
546 // order. Non-replaced siblings remain in place; inserting at
547 // index `i` only shifts elements from `i` onwards by one slot,
548 // which is exactly the semantics we want.
549 let new_set: std::collections::HashSet<Element> =
550 pairs.iter().filter_map(|(_, n)| *n).collect();
551 for (idx, child) in desired.iter().enumerate() {
552 if new_set.contains(child) {
553 crate::view::insert_child_at(*parent, *child, idx);
554 }
555 }
556 }
557
558 // 4. Update each MountSite to point at its new owner + new root.
559 for (mount_id, _, _, new_root, new_owner) in &results {
560 with_runtime(|rt| {
561 if let Some(site) = rt.mount_sites.get_mut(mount_id) {
562 site.owner = Some(*new_owner);
563 site.body_root = Some(*new_root);
564 }
565 });
566 }
567
568 // 5. Refresh anchors based on the now-final parent children
569 // layout — otherwise a *future* solo patch of one of these
570 // siblings would inherit a stale anchor and fall back to
571 // index 0 again.
572 for (mount_id, parent, _, new_root, _) in &results {
573 let new_anchor = crate::view::previous_sibling(*parent, *new_root);
574 with_runtime(|rt| {
575 if let Some(site) = rt.mount_sites.get_mut(mount_id) {
576 site.anchor = new_anchor;
577 }
578 });
579 }
580}