Skip to main content

whisker_runtime/view/
renderer.rs

1//! Type-erased renderer + thread-local current-renderer plumbing.
2//!
3//! The `render!` macro emits calls to the free functions in this
4//! module ([`create_element`], [`set_attribute`], …). Each looks up
5//! the currently-installed [`DynRenderer`] from a `thread_local!`
6//! slot and forwards. This keeps the macro output renderer-agnostic
7//! while still letting tests swap in a `MockRenderer`.
8//!
9//! Lifecycle:
10//!
11//! ```ignore
12//! let renderer = Box::new(MyRenderer::new());
13//! let prev = install_renderer(renderer);
14//! // … all `view::create_element` etc. calls now go to MyRenderer
15//! uninstall_renderer(prev);                 // restore previous (None)
16//! ```
17//!
18//! In production the bridge driver installs the Lynx-backed renderer
19//! once at startup and keeps it for the life of the process.
20
21use std::cell::{Cell, RefCell};
22use std::collections::{HashMap, HashSet};
23use std::rc::Rc;
24
25use super::handle::Element;
26use crate::element::ElementTag;
27use crate::value::WhiskerValue;
28
29/// Event-handler propagation type — a faithful 1:1 mapping to Lynx's
30/// four handler kinds (`bind` / `catch` / `capture-bind` /
31/// `capture-catch`). The variant chosen when registering a listener is
32/// what drives Lynx's native event chain:
33///
34///   - **phase**: capture handlers fire on the way *down* (root →
35///     target); bind/catch (bubble) handlers fire on the way *up*
36///     (target → root).
37///   - **stop**: a `catch` handler stops propagation after it fires;
38///     a `bind` handler lets the event continue along the chain.
39///
40/// The discriminants match `lynx_event_bind_type_e` in the C bridge,
41/// so the value crosses the FFI as a plain `i32`.
42#[repr(i32)]
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum BindType {
45    /// `bind` — bubble phase, does not stop propagation. The default
46    /// (what plain `on_<event>` registers).
47    #[default]
48    Bind = 0,
49    /// `catch` — bubble phase, stops propagation at this element.
50    Catch = 1,
51    /// `capture-bind` — capture phase, does not stop propagation.
52    CaptureBind = 2,
53    /// `capture-catch` — capture phase, stops propagation.
54    CaptureCatch = 3,
55}
56
57/// One planned listener firing: the listener plus the event value it
58/// should receive (its `currentTarget` already rewritten to that
59/// listener's element).
60pub type EventFiring = (Rc<dyn Fn(WhiskerValue) + 'static>, WhiskerValue);
61
62/// The ordered firing plan produced by
63/// [`DynRenderer::plan_event_dispatch`]. Separates *planning* (done
64/// under the renderer borrow) from *firing* (done after the borrow is
65/// released, since a handler may re-enter the renderer).
66#[derive(Default)]
67pub struct EventDispatchPlan {
68    /// Whether any listener matched — relayed to the platform reporter
69    /// so Lynx can skip its own native chain for this event.
70    pub consumed: bool,
71    /// Listeners to invoke, in propagation order.
72    pub firings: Vec<EventFiring>,
73}
74
75/// Object-safe renderer trait. The renderer owns whatever per-element
76/// state it needs and answers in `Element` IDs.
77///
78/// Mirrors the shape of [`crate::renderer::Renderer`] but is
79/// type-erased — the handle type is always [`Element`]. Existing
80/// `R: Renderer` implementations bridge into here via a small adapter
81/// that maintains its own `Element → R::Element` map.
82pub trait DynRenderer {
83    fn create_element(&mut self, tag: ElementTag) -> Element;
84    /// Phase 7: tag-by-name dispatch for custom / xelement-style
85    /// tags ("x-input", etc.) not in the built-in [`ElementTag`]
86    /// enum. Returns [`Element::INVALID`] when the tag is unknown
87    /// to Lynx's behaviour registry.
88    fn create_element_by_name(&mut self, tag_name: &str) -> Element;
89    fn release_element(&mut self, handle: Element);
90
91    fn set_attribute(&mut self, handle: Element, key: &str, value: &str);
92    /// Typed-attr variants. Lynx's prop dispatch on many UIs
93    /// (`<list>`, `<scroll-view>`, …) gates branches on
94    /// `value.IsNumber()` / `value.IsBool()` against the underlying
95    /// `lepus::Value`, so a stringified attr from
96    /// [`set_attribute`](Self::set_attribute) silently no-ops in
97    /// those branches. Use these for any prop whose Lynx handler
98    /// reads the value as anything other than a string. Default
99    /// impls forward to the string path (good enough for test
100    /// renderers that don't model the underlying type discrimination).
101    fn set_attribute_int(&mut self, handle: Element, key: &str, value: i64) {
102        self.set_attribute(handle, key, &value.to_string());
103    }
104    fn set_attribute_bool(&mut self, handle: Element, key: &str, value: bool) {
105        self.set_attribute(handle, key, if value { "true" } else { "false" });
106    }
107    fn set_attribute_double(&mut self, handle: Element, key: &str, value: f64) {
108        self.set_attribute(handle, key, &value.to_string());
109    }
110    fn set_inline_styles(&mut self, handle: Element, css: &str);
111
112    /// Underlying Lynx sign (`impl_id`) for `handle`, or 0 if the
113    /// renderer doesn't model signs (test renderers) or the handle
114    /// is unknown. The list provider closure needs this to tell the
115    /// C++ list which FiberElement to bind to an `index`. Whisker's
116    /// own [`Element`] is a Vec index inside the renderer and is
117    /// **not** the same number as Lynx's `impl_id`.
118    fn element_sign(&self, _handle: Element) -> i32 {
119        0
120    }
121
122    /// Hand a `<list>` element its item count so the bridge can build
123    /// the `update-list-info` map (positional item-keys `w_<i>`) that
124    /// Lynx's decoupled native list reads its items from. The `list`
125    /// builder calls this once at `__h()` finalize. Default no-op for
126    /// test renderers that don't model list virtualisation.
127    fn set_update_list_info(&mut self, _handle: Element, _count: i32) {}
128
129    /// Install a native item provider on a `<list>` element. The
130    /// `provider`'s callbacks are invoked by Lynx's list machinery to
131    /// fetch / recycle item elements on demand. Returns `true` if the
132    /// install reached the bridge — `false` is reported when the
133    /// renderer has no live native handle for `_handle` or doesn't
134    /// model list virtualisation (test renderers default here).
135    /// The default drops `provider` so test code doesn't leak boxed
136    /// closures.
137    fn install_list_native_item_provider(
138        &mut self,
139        _handle: Element,
140        provider: super::list_provider::NativeItemProvider,
141    ) -> bool {
142        drop(provider);
143        false
144    }
145
146    fn append_child(&mut self, parent: Element, child: Element);
147    fn remove_child(&mut self, parent: Element, child: Element);
148
149    /// Register `callback` for `event_name` on `handle`.
150    ///
151    /// The callback receives the event body Lynx hands the handler
152    /// as a [`WhiskerValue`] tree (the same wire as module
153    /// args/returns). A built-in builder's `on_<event>` method or a
154    /// `#[whisker::module_component]` `on_<event>` prop wraps a
155    /// typed-event / unit / raw-value closure into this single
156    /// shape, deserializing the payload as needed. An event with no
157    /// body fires the callback with [`WhiskerValue::Null`].
158    fn set_event_listener(
159        &mut self,
160        handle: Element,
161        event_name: &str,
162        bind_type: BindType,
163        callback: Box<dyn Fn(WhiskerValue) + 'static>,
164    );
165
166    /// Plan how a reported event (`event_name` at `target_sign`,
167    /// carrying `body`) propagates through Whisker's reconstructed
168    /// chain — capture phase (root → target) then bubble phase
169    /// (target → root), honoring each registered listener's
170    /// [`BindType`] (catch stops bubbling; capture-catch stops
171    /// everything).
172    ///
173    /// Returns the listeners to fire **in order**, each paired with the
174    /// event value it should receive (its `currentTarget` set to that
175    /// listener's element), plus whether the event was consumed.
176    ///
177    /// Crucially this only *plans* — it does not fire the listeners,
178    /// because firing happens after the renderer borrow is released
179    /// (a handler may mutate signals → effects → re-enter the
180    /// renderer). [`dispatch_event`] does the firing. The default impl
181    /// plans nothing (renderers without a native event source); the
182    /// Lynx bridge renderer overrides it.
183    fn plan_event_dispatch(
184        &self,
185        _target_sign: i32,
186        _event_name: &str,
187        _body: &WhiskerValue,
188    ) -> EventDispatchPlan {
189        EventDispatchPlan::default()
190    }
191
192    fn set_root(&mut self, page: Element);
193    fn flush(&mut self);
194
195    /// Opaque platform pointer the C bridge associates with this
196    /// `Element` handle (cast from `*mut WhiskerElement` for the
197    /// Lynx bridge renderer; `0` for renderers without a native
198    /// backing).
199    ///
200    /// Used by `whisker-driver`'s `ElementRef::invoke` to call
201    /// `whisker_bridge_invoke_element_method` without the runtime
202    /// crate having to know about the bridge's C types. Renderers
203    /// that don't have a native pointer return `0`, which the
204    /// driver surfaces as `WhiskerValue::Error` to the caller.
205    ///
206    /// Phase 7-Φ.H.2.3.
207    fn module_component_ptr(&self, _handle: Element) -> usize {
208        0
209    }
210}
211
212thread_local! {
213    /// The active renderer for this thread. `None` outside any mount.
214    ///
215    /// Wrapped in `RefCell<Option<Box<dyn>>>` rather than holding the
216    /// renderer directly so [`install_renderer`] can swap one out for
217    /// another atomically and tests can run with no renderer installed
218    /// (where dispatch functions silently no-op + warn).
219    static CURRENT_RENDERER: RefCell<Option<Box<dyn DynRenderer>>> = const { RefCell::new(None) };
220
221    /// Whisker-side mirror of every parent → ordered-children
222    /// relationship the runtime has emitted. Maintained by
223    /// [`append_child`] / [`remove_child`].
224    ///
225    /// Used by `mount_component_remountable` (#17 wrapper-removal
226    /// follow-up) to compute the "previous sibling at mount time"
227    /// anchor without asking Lynx — Lynx's C API doesn't expose a
228    /// child-position query, and we'd rather not add one. Side
229    /// effect: the mirror also enables `previous_sibling` /
230    /// `next_sibling` queries for any future need (e.g. insert_after
231    /// shimming when we ship the wrapper-less remount path).
232    static CHILDREN_OF: RefCell<HashMap<Element, Vec<Element>>> =
233        RefCell::new(HashMap::new());
234
235    /// Reverse direction of [`CHILDREN_OF`]: child → its mirror
236    /// parent. Maintained in lockstep with [`append_child`] /
237    /// [`remove_child`]. We need this to walk *up* the mirror — the
238    /// [phantom hoisting](create_phantom_element) machinery looks for
239    /// the nearest non-phantom ancestor on every tree mutation, and
240    /// the mirror-only direction is the only place that information
241    /// lives.
242    ///
243    /// Each child has at most one parent (we don't model the DOM's
244    /// "move from one parent to another" — every move is detach +
245    /// re-attach through us). Missing-entry = the child is currently
246    /// detached (no parent).
247    static PARENT_OF: RefCell<HashMap<Element, Element>> =
248        RefCell::new(HashMap::new());
249
250    /// IDs allocated by [`create_phantom_element`]. A phantom is an
251    /// Element that lives in [`CHILDREN_OF`] / [`PARENT_OF`] but is
252    /// **not** present in Lynx. It behaves like a *transparent
253    /// container*: any real child mounted under a phantom is hoisted
254    /// to the phantom's nearest non-phantom ancestor in Lynx; if
255    /// there is no such ancestor yet (the phantom is still
256    /// unattached), the real children stay in the mirror only and
257    /// land in Lynx when the phantom subtree is finally attached.
258    static PHANTOM_ELEMENTS: RefCell<HashSet<Element>> =
259        RefCell::new(HashSet::new());
260
261    /// Monotonic counter for phantom IDs, starting at [`PHANTOM_BASE`]
262    /// (`1 << 31`). The bridge renderer allocates real IDs from 0
263    /// upward, so the two ranges can't realistically collide (a
264    /// session would need 2 billion real elements before the real
265    /// counter reached `PHANTOM_BASE`).
266    static NEXT_PHANTOM_ID: Cell<u32> = const { Cell::new(PHANTOM_BASE) };
267}
268
269/// Phantom IDs occupy the high half of `u32`; real IDs start at 0
270/// from the bridge renderer's counter, so the two ranges stay
271/// disjoint without coordination.
272pub const PHANTOM_BASE: u32 = 1 << 31;
273
274/// Install `r` as the current renderer for this thread, returning
275/// whatever renderer was installed before (so the caller can restore
276/// it later if needed).
277///
278/// Most production callers install exactly once and never restore.
279/// Tests use the returned previous value to reset between cases.
280pub fn install_renderer(r: Box<dyn DynRenderer>) -> Option<Box<dyn DynRenderer>> {
281    CURRENT_RENDERER.with_borrow_mut(|slot| slot.replace(r))
282}
283
284/// Remove the current renderer, returning it to the caller. The
285/// thread-local slot is left `None`. Subsequent dispatch calls warn
286/// (in debug) and no-op.
287pub fn uninstall_renderer(prev: Option<Box<dyn DynRenderer>>) {
288    CURRENT_RENDERER.with_borrow_mut(|slot| *slot = prev);
289}
290
291/// Run `f` with `r` temporarily installed as the current renderer.
292/// Restores whatever was previously installed when `f` returns
293/// (including the `None` state). Useful for tests + scoped
294/// rendering.
295pub fn with_installed_renderer<R>(r: Box<dyn DynRenderer>, f: impl FnOnce() -> R) -> R {
296    let prev = install_renderer(r);
297    let result = f();
298    let _new = CURRENT_RENDERER.with_borrow_mut(|slot| slot.take());
299    if let Some(p) = prev {
300        let _ = install_renderer(p);
301    }
302    result
303}
304
305/// Crate-internal sigil for "no renderer installed" diagnostics —
306/// distinguishes "renderer panicked" from "no renderer in this
307/// scope" in tests.
308pub fn current_renderer_id() -> Option<&'static str> {
309    CURRENT_RENDERER.with_borrow(|slot| slot.as_ref().map(|_| "installed"))
310}
311
312fn with_renderer<R>(f: impl FnOnce(&mut dyn DynRenderer) -> R, default: R) -> R {
313    CURRENT_RENDERER.with_borrow_mut(|slot| match slot.as_mut() {
314        Some(r) => f(r.as_mut()),
315        None => {
316            #[cfg(debug_assertions)]
317            eprintln!("whisker-view: renderer call outside any installed renderer; ignored");
318            default
319        }
320    })
321}
322
323// Free-function dispatch — what the `render!` macro and reactive
324// effects call.
325
326/// Free-fn helper used by the `render!` macro and reactive effects to
327/// allocate an element of any tag the bridge knows. Routes both the
328/// built-in `ElementTag` enum and tag-by-name strings through the
329/// same owner-tracking + invalid-handle logic.
330pub fn create_element_by_name(tag_name: &str) -> Element {
331    let handle = with_renderer(|r| r.create_element_by_name(tag_name), Element(u32::MAX));
332    if handle.id() != u32::MAX {
333        crate::reactive::with_runtime(|rt| {
334            if let Some(owner_id) = rt.current_owner() {
335                if let Some(owner) = rt.owners.get_mut(owner_id) {
336                    owner.elements.push(handle);
337                }
338            }
339        });
340    }
341    handle
342}
343
344pub fn create_element(tag: ElementTag) -> Element {
345    let handle = with_renderer(|r| r.create_element(tag), Element(u32::MAX));
346    // Register the element with the current reactive owner so
347    // `Owner::dispose` releases it. Without this, `BridgeRenderer`'s
348    // element map (and Lynx FiberElement refcounts) accumulate across
349    // `<Show>` flips, `<For>` removals, and component remounts.
350    if handle.id() != u32::MAX {
351        crate::reactive::with_runtime(|rt| {
352            if let Some(owner_id) = rt.current_owner() {
353                if let Some(owner) = rt.owners.get_mut(owner_id) {
354                    owner.elements.push(handle);
355                }
356            }
357        });
358    }
359    handle
360}
361
362pub fn release_element(handle: Element) {
363    if is_phantom(handle) {
364        // Phantom never reached Lynx; tear down mirror state only.
365        PHANTOM_ELEMENTS.with_borrow_mut(|s| {
366            s.remove(&handle);
367        });
368        CHILDREN_OF.with_borrow_mut(|m| {
369            m.remove(&handle);
370        });
371        PARENT_OF.with_borrow_mut(|m| {
372            m.remove(&handle);
373        });
374        return;
375    }
376    with_renderer(|r| r.release_element(handle), ())
377}
378
379/// Allocate a phantom element — an opaque positional marker the
380/// runtime registers in the mirror but **never** forwards to Lynx.
381/// Phantoms behave as *transparent containers*: any real descendant
382/// attached under a phantom is hoisted to the phantom's nearest
383/// non-phantom mirror ancestor in Lynx, preserving source order.
384///
385/// Phantom IDs come from [`NEXT_PHANTOM_ID`], starting at
386/// [`PHANTOM_BASE`] (`1 << 31`); the bridge renderer's real-element
387/// counter starts at 0, so the two ranges are disjoint in any
388/// realistic session.
389///
390/// Owner-tracking parity: the freshly-allocated phantom is added to
391/// the currently-active reactive owner's `elements` list, so the
392/// same dispose-cascade that releases real elements also reaches
393/// phantoms — [`release_element`] detects the phantom case and
394/// clears its mirror + set membership without touching Lynx.
395///
396/// **Use case**: the wrapper-less `fragment` builtin and the
397/// `For` / `Show` control-flow components — each allocates one
398/// phantom as its "transparent grouping" element so its reactive
399/// children appear in the user's mirror tree as a group while
400/// landing in Lynx as flat siblings of the surrounding non-phantom
401/// container.
402pub fn create_phantom_element() -> Element {
403    let id = NEXT_PHANTOM_ID.with(|c| {
404        let id = c.get();
405        c.set(id.wrapping_add(1));
406        id
407    });
408    let handle = Element::from_raw(id);
409    PHANTOM_ELEMENTS.with_borrow_mut(|s| {
410        s.insert(handle);
411    });
412    crate::reactive::with_runtime(|rt| {
413        if let Some(owner_id) = rt.current_owner() {
414            if let Some(owner) = rt.owners.get_mut(owner_id) {
415                owner.elements.push(handle);
416            }
417        }
418    });
419    handle
420}
421
422/// Whether `handle` was allocated by [`create_phantom_element`].
423/// Cheap thread-local lookup — the bridge dispatchers below call
424/// this on every tree-mutation to decide whether to skip the FFI
425/// step.
426pub fn is_phantom(handle: Element) -> bool {
427    if handle.id() < PHANTOM_BASE {
428        return false;
429    }
430    PHANTOM_ELEMENTS.with_borrow(|s| s.contains(&handle))
431}
432
433/// Walk *up* the mirror from `start` (not including `start` itself)
434/// until a non-phantom ancestor is found. Returns `None` if `start`
435/// has no parent or the entire chain to the root is phantoms.
436///
437/// `start` may itself be either a phantom or a real element — the
438/// function just looks at its ancestors. For the hoisting path the
439/// caller usually passes the *parent* of the just-mutated child,
440/// because the child's own type isn't what determines the
441/// effective Lynx parent; the surrounding tree is.
442fn nearest_real_ancestor(start: Element) -> Option<Element> {
443    let mut current = start;
444    loop {
445        let parent = PARENT_OF.with_borrow(|m| m.get(&current).copied())?;
446        if !is_phantom(parent) {
447            return Some(parent);
448        }
449        current = parent;
450    }
451}
452
453/// Count the number of *real* (non-phantom) elements reachable from
454/// `root` through a strictly transparent path (phantom-only ancestors
455/// between `root` and the reached element) that appear in DFS
456/// pre-order before `target`. Used to compute the Lynx-side position
457/// at which a newly-attached real element should land in
458/// [`nearest_real_ancestor(target)`].
459///
460/// Excludes `root` itself; counts real descendants only. If `target`
461/// is not under `root`, returns the total count (= "append at end").
462fn count_real_descendants_before(root: Element, target: Element) -> usize {
463    fn walk(node: Element, target: Element, count: &mut usize, found: &mut bool) {
464        if *found {
465            return;
466        }
467        let children = CHILDREN_OF.with_borrow(|m| m.get(&node).cloned().unwrap_or_default());
468        for child in children {
469            if *found {
470                return;
471            }
472            if child == target {
473                *found = true;
474                return;
475            }
476            if is_phantom(child) {
477                walk(child, target, count, found);
478            } else {
479                *count += 1;
480            }
481        }
482    }
483    let mut count = 0usize;
484    let mut found = false;
485    walk(root, target, &mut count, &mut found);
486    count
487}
488
489/// DFS pre-order collect every real (non-phantom) descendant of
490/// `root` reachable through a strictly transparent chain (phantom-
491/// only ancestors). Used when a phantom subtree gets attached to a
492/// real parent — we walk it and hand the real descendants to Lynx
493/// in the right order.
494fn collect_transparent_real_descendants(root: Element) -> Vec<Element> {
495    let mut out = Vec::new();
496    fn walk(node: Element, out: &mut Vec<Element>) {
497        let children = CHILDREN_OF.with_borrow(|m| m.get(&node).cloned().unwrap_or_default());
498        for child in children {
499            if is_phantom(child) {
500                walk(child, out);
501            } else {
502                out.push(child);
503            }
504        }
505    }
506    walk(root, &mut out);
507    out
508}
509
510pub fn set_attribute(handle: Element, key: &str, value: &str) {
511    if is_phantom(handle) {
512        return; // phantoms carry no Lynx-side styling — silently no-op
513    }
514    with_renderer(|r| r.set_attribute(handle, key, value), ())
515}
516
517pub fn set_attribute_int(handle: Element, key: &str, value: i64) {
518    if is_phantom(handle) {
519        return;
520    }
521    with_renderer(|r| r.set_attribute_int(handle, key, value), ())
522}
523
524pub fn set_attribute_bool(handle: Element, key: &str, value: bool) {
525    if is_phantom(handle) {
526        return;
527    }
528    with_renderer(|r| r.set_attribute_bool(handle, key, value), ())
529}
530
531pub fn set_attribute_double(handle: Element, key: &str, value: f64) {
532    if is_phantom(handle) {
533        return;
534    }
535    with_renderer(|r| r.set_attribute_double(handle, key, value), ())
536}
537
538pub fn set_inline_styles(handle: Element, css: &str) {
539    if is_phantom(handle) {
540        return;
541    }
542    with_renderer(|r| r.set_inline_styles(handle, css), ())
543}
544
545/// See [`DynRenderer::element_sign`]. Returns 0 when no renderer is
546/// installed (e.g. test setups using the mock renderer) or when
547/// `handle` is a phantom (phantoms have no Lynx `impl_id`).
548pub fn element_sign(handle: Element) -> i32 {
549    if is_phantom(handle) {
550        return 0;
551    }
552    with_renderer(|r| r.element_sign(handle), 0)
553}
554
555pub fn set_update_list_info(handle: Element, count: i32) {
556    if is_phantom(handle) {
557        return;
558    }
559    with_renderer(|r| r.set_update_list_info(handle, count), ())
560}
561
562pub fn install_list_native_item_provider(
563    handle: Element,
564    provider: super::list_provider::NativeItemProvider,
565) -> bool {
566    if is_phantom(handle) {
567        drop(provider);
568        return false;
569    }
570    with_renderer(
571        |r| r.install_list_native_item_provider(handle, provider),
572        false,
573    )
574}
575
576/// Append `child` as the last mirror child of `parent`. The Lynx-
577/// side effect depends on whether either end of the edge is a
578/// phantom:
579///
580///   - both real → the bridge sees `append_child(parent, child)`
581///     exactly as before.
582///   - phantom child → no FFI for `child` itself (it never reaches
583///     Lynx); if `child` brings a transparent subtree of real
584///     descendants with it, they're replayed into the nearest real
585///     ancestor at the position the parent's transparent layout
586///     puts them.
587///   - phantom parent → `child` is hoisted up the phantom chain to
588///     the nearest real ancestor (if any); inserted there at the
589///     position the mirror order puts it.
590///   - phantom parent with no real ancestor → no Lynx call at all;
591///     the subtree is queued in the mirror only. When the topmost
592///     phantom is later attached to a real ancestor, the same
593///     replay path handles the queued descendants in source order.
594pub fn append_child(parent: Element, child: Element) {
595    // Mirror update — unconditional.
596    CHILDREN_OF.with_borrow_mut(|map| {
597        map.entry(parent).or_default().push(child);
598    });
599    PARENT_OF.with_borrow_mut(|map| {
600        map.insert(child, parent);
601    });
602
603    // Lynx-side effect depends on phantom-ness of either end.
604    let parent_is_phantom = is_phantom(parent);
605    let child_is_phantom = is_phantom(child);
606    if parent_is_phantom {
607        // Hoist into the nearest real ancestor. When no real ancestor
608        // exists yet (topmost phantom still detached), skip the
609        // bridge step — the next attach will replay things.
610        if let Some(real_anc) = nearest_real_ancestor(parent) {
611            let to_attach: Vec<Element> = if child_is_phantom {
612                collect_transparent_real_descendants(child)
613            } else {
614                vec![child]
615            };
616            for real in to_attach {
617                let pos = count_real_descendants_before(real_anc, real);
618                bridge_insert_or_append(real_anc, real, pos);
619            }
620        }
621    } else if child_is_phantom {
622        // Phantom child carries a transparent subtree; replay any
623        // real descendants now in DFS pre-order.
624        for real in collect_transparent_real_descendants(child) {
625            let pos = count_real_descendants_before(parent, real);
626            bridge_insert_or_append(parent, real, pos);
627        }
628    } else {
629        with_renderer(|r| r.append_child(parent, child), ());
630    }
631
632    // Wrapper-less component mount handshake: if `child` is the body
633    // root of a freshly-mounted `#[component]`, its MountSite now
634    // learns where it landed (parent + previous sibling). Hot-reload
635    // remount uses this to keep mount sites anchored across patches.
636    crate::reactive::on_component_root_attached(parent, child);
637}
638
639/// Detach `child` from `parent` in the mirror. Lynx-side: any real
640/// descendants of `child` (or `child` itself if it's real) are
641/// removed from the nearest real ancestor.
642pub fn remove_child(parent: Element, child: Element) {
643    let parent_is_phantom = is_phantom(parent);
644    let child_is_phantom = is_phantom(child);
645
646    if parent_is_phantom {
647        if let Some(real_anc) = nearest_real_ancestor(parent) {
648            let to_detach: Vec<Element> = if child_is_phantom {
649                collect_transparent_real_descendants(child)
650            } else {
651                vec![child]
652            };
653            for real in to_detach {
654                with_renderer(|r| r.remove_child(real_anc, real), ());
655            }
656        }
657    } else if child_is_phantom {
658        for real in collect_transparent_real_descendants(child) {
659            with_renderer(|r| r.remove_child(parent, real), ());
660        }
661    } else {
662        with_renderer(|r| r.remove_child(parent, child), ());
663    }
664
665    CHILDREN_OF.with_borrow_mut(|map| {
666        if let Some(children) = map.get_mut(&parent) {
667            children.retain(|c| *c != child);
668        }
669    });
670    PARENT_OF.with_borrow_mut(|map| {
671        map.remove(&child);
672    });
673}
674
675/// Internal helper: ask the bridge to place `real_child` at
676/// `position` inside `real_parent`'s Lynx child list. The C ABI
677/// doesn't expose `insert_at`, so we simulate by appending and
678/// rotating: every real sibling that should sit *after* the child
679/// (per the mirror's DFS pre-order of real-only descendants) is
680/// detached and re-appended, ending up to the right of the new
681/// child. O(siblings_to_move) bridge calls.
682fn bridge_insert_or_append(real_parent: Element, real_child: Element, position: usize) {
683    // Append lands the child at the tail in Lynx.
684    with_renderer(|r| r.append_child(real_parent, real_child), ());
685
686    // Mirror already includes the child at its target slot; compute
687    // the DFS real-only order to find the siblings that need to be
688    // rotated past it.
689    let real_descendants = collect_transparent_real_descendants(real_parent);
690
691    // Everything after `position` in mirror order must end up to the
692    // right of `real_child` in Lynx — detach and re-append in order.
693    // The "after" slice excludes `real_child` itself (at
694    // `real_descendants[position]`).
695    if position + 1 < real_descendants.len() {
696        let to_move: Vec<Element> = real_descendants[position + 1..].to_vec();
697        for sib in &to_move {
698            with_renderer(|r| r.remove_child(real_parent, *sib), ());
699        }
700        for sib in &to_move {
701            with_renderer(|r| r.append_child(real_parent, *sib), ());
702        }
703    }
704}
705
706/// Insert `child` into `parent`'s child list at position `index`.
707/// If `index >= current_len`, behaves like [`append_child`].
708///
709/// First-pass implementation: Lynx's C ABI doesn't yet expose
710/// `insert_before` / `insert_at`, so we simulate ordered insertion
711/// by detaching every sibling at or after `index`, appending the
712/// new child, then re-appending the detached siblings in order. The
713/// O(N) cost is fine for `<For>` reorders and #[component] remounts
714/// where N is the parent's current child count. Replace with a
715/// direct Lynx API once the bridge gains one.
716pub fn insert_child_at(parent: Element, child: Element, index: usize) {
717    let to_re_append: Vec<Element> = CHILDREN_OF.with_borrow(|map| {
718        map.get(&parent)
719            .map(|children| {
720                if index >= children.len() {
721                    Vec::new()
722                } else {
723                    children[index..].to_vec()
724                }
725            })
726            .unwrap_or_default()
727    });
728    for c in &to_re_append {
729        remove_child(parent, *c);
730    }
731    append_child(parent, child);
732    for c in to_re_append {
733        append_child(parent, c);
734    }
735}
736
737/// Return the element handle that appears immediately before `child`
738/// in `parent`'s child list, or `None` if `child` is the first child
739/// or `parent` has no recorded children.
740pub fn previous_sibling(parent: Element, child: Element) -> Option<Element> {
741    CHILDREN_OF.with_borrow(|map| {
742        let children = map.get(&parent)?;
743        let idx = children.iter().position(|c| *c == child)?;
744        if idx == 0 {
745            None
746        } else {
747            Some(children[idx - 1])
748        }
749    })
750}
751
752/// Index of `child` in `parent`'s ordered child list, or `None` if
753/// not tracked. Used by the wrapper-less remount path to re-insert
754/// the new body root at the same position as the old one.
755pub fn child_index(parent: Element, child: Element) -> Option<usize> {
756    CHILDREN_OF.with_borrow(|map| {
757        let children = map.get(&parent)?;
758        children.iter().position(|c| *c == child)
759    })
760}
761
762/// Snapshot of `parent`'s current ordered child list. Empty Vec if
763/// the parent has no tracked children. Used by the batched
764/// `remount_components_for` so it can compute the final desired
765/// child order before any mutation churns the indices.
766pub fn children_of(parent: Element) -> Vec<Element> {
767    CHILDREN_OF.with_borrow(|map| map.get(&parent).cloned().unwrap_or_default())
768}
769
770/// Test/internal: clear the parent → children mirror. Call between
771/// scenarios that share a thread (the production runtime never
772/// needs this).
773#[doc(hidden)]
774pub fn __reset_children_mirror_for_tests() {
775    CHILDREN_OF.with_borrow_mut(|map| map.clear());
776}
777
778pub fn set_event_listener(
779    handle: Element,
780    event_name: &str,
781    bind_type: BindType,
782    callback: Box<dyn Fn(WhiskerValue) + 'static>,
783) {
784    if is_phantom(handle) {
785        // Phantoms aren't in Lynx's event chain.
786        drop(callback);
787        return;
788    }
789    with_renderer(
790        |r| r.set_event_listener(handle, event_name, bind_type, callback),
791        (),
792    )
793}
794
795/// Dispatch a reported event through the installed renderer's
796/// reconstructed propagation chain. The driver's C entry point (the
797/// bridge reporter forwards here) calls this. Returns whether the
798/// event was consumed.
799///
800/// Planning runs under the renderer borrow; the listeners then fire
801/// **after** the borrow is released, so a handler is free to mutate
802/// signals / re-enter `view::*` without a re-entrant borrow panic.
803pub fn dispatch_event(target_sign: i32, event_name: &str, body: WhiskerValue) -> bool {
804    let plan = with_renderer(
805        |r| r.plan_event_dispatch(target_sign, event_name, &body),
806        EventDispatchPlan::default(),
807    );
808    for (listener, event) in plan.firings {
809        listener(event);
810    }
811    plan.consumed
812}
813
814pub fn set_root(page: Element) {
815    with_renderer(|r| r.set_root(page), ())
816}
817
818pub fn flush() {
819    with_renderer(|r| r.flush(), ())
820}
821
822/// Opaque platform pointer for `handle`. Phase 7-Φ.H.2.3 — used by
823/// `whisker-driver`'s `ElementRef::invoke` to call the C bridge
824/// without leaking the bridge's `WhiskerElement*` type into the
825/// runtime crate's public surface. Returns `0` if no renderer is
826/// installed or the renderer doesn't have a native pointer for
827/// `handle`.
828pub fn module_component_ptr(handle: Element) -> usize {
829    if is_phantom(handle) {
830        return 0;
831    }
832    CURRENT_RENDERER.with_borrow(|slot| match slot.as_ref() {
833        Some(r) => r.module_component_ptr(handle),
834        None => 0,
835    })
836}