Skip to main content

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}