git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit1d538c6e08784cd2995c6b31d4271ce286bb265c
parentb00b57412b
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 15:15
fix(context): the registry never hands out a dropped widget's pointer

UiContext's tree holds raw pointers to widgets the app owns, and an app
that dropped a registered widget without unregistering it (a rebuilt Vec
of rows) left a dangling pointer the next registry sweep dereferenced.
Every widget base now carries a Liveness token; each tree entry keeps a
Weak watch on it, and every accessor resolves a pointer only while its
widget exists. A clone gets a token of its own. A widget MOVED while
registered is still not caught (the token moves with it); the sound end
state is a registry that owns its widgets.

The pointer-taking entry points say what they require:
WidgetTree::register, set_focused_ptr, show_context_menu and
handle_right_click are unsafe fn. register_widget stays safe for its 105
app call sites; register_host(&mut w) is the safe form for new code.
Adapted::add_child, called by nothing, is gone, and set_parent no longer
registers a *mut derived from &self.

a_dropped_widget_is_never_handed_out and a_clone_has_a_liveness_of_its_own
are the tests. All 29 dependent apps check unchanged but cce-gallery (one
call moved into its unsafe block); cce-system-interface and cce-files
driven in a shadow (focus, a filter rebuilding its rows, popovers, both
context-menu paths, navigation) behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                        |  20 ++++++
 src/context.rs                   |  61 +++++++++++++----
 src/scene/tree.rs                | 138 +++++++++++++++++++++++++++++----------
 src/widget/container/treelist.rs |  15 +++--
 src/widget/core.rs               |  46 +++++++++++++
 src/widget/model.rs              |  28 ++------
 6 files changed, 234 insertions(+), 74 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 1f57aa5..6f859a7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1567,6 +1567,26 @@ are GONE from the trait — events route through `handle_event`, and apps drain
 through the concrete inherent `Adapted<W>` methods. See the RFC's blueprint notes before
 adding anything to this trait.
 
+### The registry holds pointers, and knows when they die
+
+`UiContext`'s tree (`scene::tree::WidgetTree`) does not own its widgets: the app does, and
+registers raw pointers to them. Each widget's `Widget` base carries a `Liveness` token, and every
+entry keeps a watch on it; every accessor (`get_ptr`, `children_ptrs`, `iter_registered`, …)
+resolves a pointer only while its widget exists. A widget dropped without `unregister_widget`
+reads back as absent instead of as freed memory. A clone gets a token of its own.
+
+What it does NOT catch is a widget MOVED while registered (a `Vec` that reallocated, a struct
+returned by value): the token moves with it. Register from a live borrow before the pass that
+reads it, as the apps' rebuilds do. The sound end state is a registry that owns its widgets.
+
+The pointer-taking entry points say so:
+- `WidgetTree::register`, `UiContext::set_focused_ptr`, `show_context_menu` and
+  `handle_right_click` are `unsafe fn`.
+- `register_widget` stays safe only because 105 app call sites use it. New code calls
+  `register_host(&mut w)`.
+
+`a_dropped_widget_is_never_handed_out` and `a_clone_has_a_liveness_of_its_own` are the tests.
+
 **Runtime verification matters here.** Several scene changes are "compiles + tests pass; runtime
 verification pending" per the RFC — the headless tests can't catch paint/event regressions. When
 changing scene wiring, `cargo run` a real client (cce-files, cce-designer, cce-graph,
diff --git a/src/context.rs b/src/context.rs
index de16d7a..3f141d5 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -495,19 +495,24 @@ impl UiContext {
         let new_ptr = unsafe {
             std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(w as *mut dyn WidgetHost)
         };
-        self.tree.register(id, new_ptr);
+        // SAFETY: derived from the live borrow we were handed.
+        unsafe { self.tree.register(id, new_ptr) };
         self.set_focused_id(id);
     }
 
-    /// Transitional pointer form (TreeList focuses its adapter via `EventCtx::host_ptr`). The
-    /// pointer must be live at the call — it is only used to derive the id and refresh the
-    /// registry, never stored.
-    pub fn set_focused_ptr(&mut self, new_ptr: *mut (dyn WidgetHost + 'static)) {
+    /// Transitional pointer form (TreeList focuses its adapter via `EventCtx::host_ptr`).
+    ///
+    /// # Safety
+    ///
+    /// `new_ptr` must be null or point to a live widget at the call. It is read to derive the
+    /// id and refresh the registry (see [`WidgetTree::register`](crate::scene::tree::WidgetTree::register)).
+    pub unsafe fn set_focused_ptr(&mut self, new_ptr: *mut (dyn WidgetHost + 'static)) {
         if new_ptr.is_null() {
             return;
         }
+        // SAFETY: the caller's contract.
         let id = unsafe { (*new_ptr).base().id() };
-        self.tree.register(id, new_ptr);
+        unsafe { self.tree.register(id, new_ptr) };
         self.set_focused_id(id);
     }
 
@@ -761,8 +766,27 @@ impl UiContext {
     // walked an empty dummy context (provably inert). Section-level keyboard nav lives
     // app-side (settings' focused_section machinery).
 
+    /// Register a widget the app owns, by reference: the safe form of
+    /// [`register_widget`](Self::register_widget). `ctx.register_host(&mut self.button)`.
+    ///
+    /// The registry keeps a pointer to the widget and resolves it only while the widget has not
+    /// been dropped (see `widget::core::Liveness`); a widget MOVED after registering must be
+    /// registered again before the next pass reads it.
+    pub fn register_host(&mut self, w: &mut (dyn WidgetHost + 'static)) {
+        let id = w.base().id();
+        self.register_widget(id, w as *mut (dyn WidgetHost + 'static));
+    }
+
+    /// Register a widget by raw pointer — the legacy form 105 app call sites still use
+    /// (`ctx.register_widget(id, w.as_ptr_mut())`). Prefer [`register_host`](Self::register_host).
+    ///
+    /// `ptr` must be null or point to a live widget AT THE CALL; it is read here. Taking a raw
+    /// pointer in a safe function is unsound in principle (clippy says so) and is kept only until
+    /// the apps have moved to `register_host`; a pointer derived from a live borrow just before
+    /// the call, which is every call site today, meets the requirement.
     pub fn register_widget(&mut self, id: WidgetId, ptr: *mut (dyn WidgetHost + 'static)) {
-        self.tree.register(id, ptr);
+        // SAFETY: the documented precondition above.
+        unsafe { self.tree.register(id, ptr) };
         // A newcomer may itself have a popover rect, so the coverage memo can no
         // longer be trusted. Pages that re-register a whole list do it before
         // dispatching, so the memo is rebuilt once and then serves every root.
@@ -895,7 +919,8 @@ impl UiContext {
     /// pointer we are handed (the occlusion walks resolve the stored id through the tree).
     pub fn register_popover(&mut self, w: &mut (dyn WidgetHost + 'static)) {
         let id = w.base().id();
-        self.tree.register(id, w as *mut (dyn WidgetHost + 'static));
+        // SAFETY: derived from the live borrow we were handed.
+        unsafe { self.tree.register(id, w as *mut (dyn WidgetHost + 'static)) };
         if !self.active_popovers.contains(&id) {
             self.active_popovers.push(id);
         }
@@ -999,16 +1024,27 @@ impl UiContext {
         crate::widget::context_menu::is_visible()
     }
 
-    pub fn show_context_menu(&mut self, x: f32, y: f32, options: Vec<String>, header_count: usize, target: *mut (dyn WidgetHost + 'static)) {
+    /// Open the shared context menu on `target`.
+    ///
+    /// # Safety
+    ///
+    /// `target` must be null or point to a live widget at the call.
+    pub unsafe fn show_context_menu(&mut self, x: f32, y: f32, options: Vec<String>, header_count: usize, target: *mut (dyn WidgetHost + 'static)) {
         if target.is_null() {
             return;
         }
+        // SAFETY: the caller's contract.
         let id = unsafe { (*target).base().id() };
-        self.tree.register(id, target);
+        unsafe { self.tree.register(id, target) };
         crate::widget::context_menu::show(x, y, options, header_count, id);
     }
 
-    pub fn handle_right_click(&mut self, target: *mut (dyn WidgetHost + 'static), px: f32, py: f32) {
+    /// Open the shared config context menu for a right-click on `target`.
+    ///
+    /// # Safety
+    ///
+    /// `target` must be null or point to a live widget at the call.
+    pub unsafe fn handle_right_click(&mut self, target: *mut (dyn WidgetHost + 'static), px: f32, py: f32) {
         if target.is_null() {
             return;
         }
@@ -1092,7 +1128,8 @@ impl UiContext {
 
         let scroll_y = crate::widget::hover_animation::get_scroll_offset();
         let adjusted_py = py - scroll_y;
-        self.show_context_menu(px, adjusted_py, options, header_count, target);
+        // SAFETY: the caller's contract, passed on.
+        unsafe { self.show_context_menu(px, adjusted_py, options, header_count, target) };
     }
 
     pub fn hide_context_menu(&mut self) {
diff --git a/src/scene/tree.rs b/src/scene/tree.rs
index ae1d538..bf05ffb 100644
--- a/src/scene/tree.rs
+++ b/src/scene/tree.rs
@@ -22,30 +22,42 @@
 //! apps (paint recursion and event propagation both read `children`). See
 //! `docs/rfc-core-rebuild.md` Phase 1b.
 //!
-//! Nothing here is wired into `UiContext` yet; this is the tested drop-in the swap will use.
+//! `UiContext` keeps its tree here (`UiContext::tree`).
+//!
+//! ## What a pointer here is worth
+//!
+//! The tree does not own its widgets; the app does, and registers raw pointers to them. Each
+//! entry also keeps a watch on the widget's liveness token (`widget::core::Liveness`), and
+//! every accessor resolves a pointer only while that token exists — so a widget dropped
+//! without being unregistered reads back as absent rather than as a pointer to freed memory.
+//! A widget MOVED while registered is not caught (its token moves with it); see `Liveness`.
 
 use std::collections::HashMap;
+use std::sync::Weak;
 
 use crate::scene::arena::{Arena, NodeId};
 use crate::widget::{WidgetHost, WidgetId};
 
-/// One arena node's payload: the widget's stable id plus its live pointer. The pointer is `None`
-/// for a node that has been *linked* into the tree (as a parent/child) but not yet *registered*
-/// with a real widget — mirroring the legacy maps, where a `layout_tree` link can precede the
-/// `widget_registry` entry. (`*mut dyn WidgetHost` is a fat pointer, so `Option` is the natural
-/// "absent" representation — there is no thin null to use as a sentinel.)
-#[derive(Clone, Copy)]
+/// One arena node's payload: the widget's stable id, its pointer, and a watch on the widget's
+/// liveness token. The pointer is `None` for a node that has been *linked* into the tree (as a
+/// parent/child) but not yet *registered* with a real widget — mirroring the legacy maps, where a
+/// `layout_tree` link can precede the `widget_registry` entry. (`*mut dyn WidgetHost` is a fat
+/// pointer, so `Option` is the natural "absent" representation — there is no thin null to use as
+/// a sentinel.) `alive` is `None` exactly when there is no non-null pointer to watch.
+#[derive(Clone)]
 struct Entry {
     id: WidgetId,
     ptr: Option<*mut (dyn WidgetHost + 'static)>,
+    alive: Option<Weak<()>>,
 }
 
-/// Resolve an entry's pointer to a usable, non-null pointer (skipping link-only and null-data
-/// pointers exactly as the legacy `filter_map` over the registry did).
+/// Resolve an entry's pointer to a usable one: non-null (skipping link-only and null-data
+/// pointers exactly as the legacy `filter_map` over the registry did) and naming a widget that
+/// has not been dropped since it was registered.
 #[inline]
 fn live_ptr(entry: &Entry) -> Option<*mut (dyn WidgetHost + 'static)> {
-    match entry.ptr {
-        Some(p) if !p.is_null() => Some(p),
+    match (entry.ptr, &entry.alive) {
+        (Some(p), Some(alive)) if !p.is_null() && alive.strong_count() > 0 => Some(p),
         _ => None,
     }
 }
@@ -85,7 +97,7 @@ impl WidgetTree {
                 return node;
             }
         }
-        let node = self.arena.insert(Entry { id, ptr: None });
+        let node = self.arena.insert(Entry { id, ptr: None, alive: None });
         self.by_id.insert(id, node);
         node
     }
@@ -93,10 +105,20 @@ impl WidgetTree {
     /// Register (or overwrite) the live pointer for `id`. Mirrors `register_widget`'s
     /// insert-overwrite semantics. Registering a `null` pointer is allowed (the node exists but
     /// resolves to `None`), matching the legacy behavior where a link can precede registration.
-    pub fn register(&mut self, id: WidgetId, ptr: *mut (dyn WidgetHost + 'static)) {
+    ///
+    /// # Safety
+    ///
+    /// `ptr` must be null or point to a live widget at the call: it is read once here, to take
+    /// a watch on the widget's liveness token. After the call the tree resolves the pointer only
+    /// while that widget has not been dropped.
+    pub unsafe fn register(&mut self, id: WidgetId, ptr: *mut (dyn WidgetHost + 'static)) {
+        // SAFETY: the caller's contract — null, or a live widget.
+        let alive = unsafe { ptr.as_ref() }.map(|w| w.base().live.watch());
         let node = self.ensure_node(id);
         // `ensure_node` guarantees the node exists.
-        self.arena.value_mut(node).unwrap().ptr = Some(ptr);
+        let entry = self.arena.value_mut(node).unwrap();
+        entry.ptr = Some(ptr);
+        entry.alive = alive;
     }
 
     /// Make `child` a child of `parent` (deduped, reparenting from any previous parent). Mirrors
@@ -258,7 +280,7 @@ mod tests {
         let mut tree = WidgetTree::new();
         let (id, ptr) = w.make(1);
         assert_eq!(tree.get_ptr(id), None, "unknown id resolves to None");
-        tree.register(id, ptr);
+        unsafe { tree.register(id, ptr) };
         assert_eq!(tree.get_ptr(id), Some(ptr));
         assert!(tree.is_registered(id));
     }
@@ -270,8 +292,8 @@ mod tests {
         let id = WidgetId(1);
         let (_, p1) = w.make(1);
         let (_, p2) = w.make(2);
-        tree.register(id, p1);
-        tree.register(id, p2); // same id, new pointer
+        unsafe { tree.register(id, p1) };
+        unsafe { tree.register(id, p2) }; // same id, new pointer
         assert_eq!(tree.get_ptr(id), Some(p2));
         assert_eq!(tree.len(), 1, "overwrite must not create a second node");
     }
@@ -282,8 +304,8 @@ mod tests {
         let mut tree = WidgetTree::new();
         let (p, pp) = w.make(1);
         let (c, cp) = w.make(2);
-        tree.register(p, pp);
-        tree.register(c, cp);
+        unsafe { tree.register(p, pp) };
+        unsafe { tree.register(c, cp) };
 
         tree.link(p, c);
         tree.link(p, c); // duplicate link is a no-op
@@ -299,9 +321,9 @@ mod tests {
         let (a, ap) = w.make(1);
         let (b, bp) = w.make(2);
         let (c, cp) = w.make(3);
-        tree.register(a, ap);
-        tree.register(b, bp);
-        tree.register(c, cp);
+        unsafe { tree.register(a, ap) };
+        unsafe { tree.register(b, bp) };
+        unsafe { tree.register(c, cp) };
 
         tree.link(a, c);
         assert_eq!(tree.child_ids(a), vec![c]);
@@ -318,7 +340,7 @@ mod tests {
         let mut w = Widgets::new();
         let mut tree = WidgetTree::new();
         let (p, pp) = w.make(1);
-        tree.register(p, pp);
+        unsafe { tree.register(p, pp) };
         let child = WidgetId(2);
 
         tree.link(p, child); // child not registered yet
@@ -326,7 +348,7 @@ mod tests {
         assert!(tree.children_ptrs(p).is_empty(), "link-only child has no pointer yet");
 
         let (_, cp) = w.make(2);
-        tree.register(child, cp);
+        unsafe { tree.register(child, cp) };
         assert_eq!(tree.children_ptrs(p), vec![cp], "now resolvable");
     }
 
@@ -338,8 +360,8 @@ mod tests {
         let mut tree = WidgetTree::new();
         let (p, pp) = w.make(1);
         let (c, cp) = w.make(2);
-        tree.register(p, pp);
-        tree.register(c, cp);
+        unsafe { tree.register(p, pp) };
+        unsafe { tree.register(c, cp) };
         tree.link(p, c);
 
         tree.set_parent(c, None);
@@ -355,9 +377,9 @@ mod tests {
         let (p, pp) = w.make(1);
         let (c1, c1p) = w.make(2);
         let (c2, c2p) = w.make(3);
-        tree.register(p, pp);
-        tree.register(c1, c1p);
-        tree.register(c2, c2p);
+        unsafe { tree.register(p, pp) };
+        unsafe { tree.register(c1, c1p) };
+        unsafe { tree.register(c2, c2p) };
         tree.link(p, c1);
         tree.link(p, c2);
 
@@ -373,8 +395,8 @@ mod tests {
         let mut tree = WidgetTree::new();
         let (p, pp) = w.make(1);
         let (c, cp) = w.make(2);
-        tree.register(p, pp);
-        tree.register(c, cp);
+        unsafe { tree.register(p, pp) };
+        unsafe { tree.register(c, cp) };
         tree.link(p, c);
 
         tree.clear_all();
@@ -391,8 +413,8 @@ mod tests {
         let mut tree = WidgetTree::new();
         let (p, pp) = w.make(1);
         let (c, cp) = w.make(2);
-        tree.register(p, pp);
-        tree.register(c, cp);
+        unsafe { tree.register(p, pp) };
+        unsafe { tree.register(c, cp) };
         tree.link(p, c);
 
         tree.remove(p); // removes p and its subtree (c)
@@ -406,10 +428,58 @@ mod tests {
         let mut w = Widgets::new();
         let mut tree = WidgetTree::new();
         let (p, pp) = w.make(1);
-        tree.register(p, pp);
+        unsafe { tree.register(p, pp) };
         tree.link(p, WidgetId(99)); // link-only, null pointer
 
         let seen: Vec<WidgetId> = tree.iter_registered().map(|(id, _)| id).collect();
         assert_eq!(seen, vec![p], "link-only (null) node is not yielded");
     }
+
+    #[test]
+    fn a_dropped_widget_is_never_handed_out() {
+        // The app rebuilt its rows and forgot to unregister the old ones: every accessor must
+        // read the dropped widgets as absent, never as a pointer into freed memory.
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (p, pp) = w.make(1);
+        let (c, cp) = w.make(2);
+        unsafe { tree.register(p, pp) };
+        unsafe { tree.register(c, cp) };
+        tree.link(p, c);
+        assert_eq!(tree.children_ptrs(p), vec![cp]);
+
+        w.boxes.remove(1); // drop the child, still registered and linked
+        assert_eq!(tree.get_ptr(c), None);
+        assert!(!tree.is_registered(c));
+        assert!(tree.children_ptrs(p).is_empty());
+        assert_eq!(tree.child_ids(p), vec![c], "the link itself is kept, as for a link-only child");
+        let seen: Vec<WidgetId> = tree.iter_registered().map(|(id, _)| id).collect();
+        assert_eq!(seen, vec![p]);
+
+        w.boxes.clear(); // and the parent
+        assert_eq!(tree.get_ptr(p), None);
+        assert_eq!(tree.parent_ptr(c), None);
+        assert_eq!(tree.iter_registered().count(), 0);
+    }
+
+    #[test]
+    fn a_clone_has_a_liveness_of_its_own() {
+        // A clone shares its original's id (the id cell is copied) but is a different widget:
+        // registering it and dropping the original must leave it resolvable, and a clone must
+        // not keep a dropped original resolvable either.
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (id, original) = w.make(1);
+        let copy = Box::new(Marker { base: w.boxes[0].base.clone(), tag: 2 });
+        unsafe { tree.register(id, original) };
+        w.boxes.clear();
+        assert_eq!(tree.get_ptr(id), None, "the clone does not keep the original alive");
+
+        let mut copy = copy;
+        let copy_ptr: *mut (dyn WidgetHost + 'static) = &mut *copy;
+        unsafe { tree.register(id, copy_ptr) };
+        assert_eq!(tree.get_ptr(id), Some(copy_ptr));
+        drop(copy);
+        assert_eq!(tree.get_ptr(id), None);
+    }
 }
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 1ff19d0..e523fdb 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -453,7 +453,8 @@ impl TreeList {
         if button == MouseButton::Left && state == ElementState::Pressed && !covered {
             let on_scrollbar = self.scroll_box.hit_test_scrollbar(px, py) || self.scroll_box.scrollbar_dragging;
             if !on_scrollbar && px >= list_left && px <= list_left + list_width && py >= list_top && py <= list_bottom {
-                if let Some(h) = host { ui.set_focused_ptr(h); }
+                // SAFETY: `host` is this widget's own adapter, live while its event is routed.
+                if let Some(h) = host { unsafe { ui.set_focused_ptr(h) }; }
                 let relative_y = py - list_top + self.scroll_box.scroll_y;
                 let row_idx = (relative_y / self.item_height) as usize;
                 if row_idx < self.items.len() {
@@ -530,7 +531,8 @@ impl TreeList {
 
         if button == MouseButton::Right && state == ElementState::Pressed && !covered {
             if px >= list_left && px <= list_left + list_width && py >= list_top && py <= list_bottom {
-                if let Some(h) = host { ui.set_focused_ptr(h); }
+                // SAFETY: `host` is this widget's own adapter, live while its event is routed.
+                if let Some(h) = host { unsafe { ui.set_focused_ptr(h) }; }
                 let relative_y = py - list_top + self.scroll_box.scroll_y;
                 let row_idx = (relative_y / self.item_height) as usize;
                 if row_idx < self.items.len() {
@@ -549,7 +551,8 @@ impl TreeList {
                             options.push("Collapse All".to_string());
                             
                             let scroll_offset = crate::widget::hover_animation::get_scroll_offset();
-                            if let Some(h) = host { ui.show_context_menu(px, py - scroll_offset, options, 1, h); }
+                            // SAFETY: as above — our own adapter, live while its event is routed.
+                            if let Some(h) = host { unsafe { ui.show_context_menu(px, py - scroll_offset, options, 1, h) }; }
                             changed = true;
                         }
                         TreeElement::Leaf { original_idx, ref path, ref name, indent, ref val } => {
@@ -569,7 +572,8 @@ impl TreeList {
                                 "Delete".to_string(),
                             ];
                             let scroll_offset = crate::widget::hover_animation::get_scroll_offset();
-                            if let Some(h) = host { ui.show_context_menu(px, py - scroll_offset, options, 1, h); }
+                            // SAFETY: as above — our own adapter, live while its event is routed.
+                            if let Some(h) = host { unsafe { ui.show_context_menu(px, py - scroll_offset, options, 1, h) }; }
                             changed = true;
                         }
                     }
@@ -1176,7 +1180,8 @@ impl Input for TreeList {
                 let eb_id = self.edit_box.base().id();
                 ui.unlink_child(host_id, eb_id);
                 ui.unregister_widget(eb_id);
-                if let Some(h) = host { ui.set_focused_ptr(h); }
+                // SAFETY: `host` is this widget's own adapter, live while its event is routed.
+                if let Some(h) = host { unsafe { ui.set_focused_ptr(h) }; }
                 changed = true;
             }
         }
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 09d6bcb..0b522b1 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -1954,6 +1954,50 @@ pub struct Widget {
     pub dirty: bool,
     pub config_file: Option<String>,
     pub config_key: Option<String>,
+    /// Lives exactly as long as this base: the registry holds a watch on it and will
+    /// not hand out the widget's pointer once it is gone. See [`Liveness`].
+    pub(crate) live: Liveness,
+}
+
+/// A token owned by a widget's [`Widget`] base, watched by the [`UiContext`] registry
+/// (`scene::tree::WidgetTree`).
+///
+/// The registry holds raw pointers to widgets the APP owns, so an app that drops a
+/// widget without unregistering it (a rebuilt `Vec` of rows — the case
+/// `UiContext::unregister_widget` warns about) used to leave a dangling pointer that
+/// the next registry sweep dereferenced. The registry now keeps a [`Weak`] to this
+/// token beside each pointer and resolves the pointer only while the token is alive,
+/// so a dropped widget reads back as unregistered instead.
+///
+/// It does NOT catch a widget that was MOVED while registered (a `Vec` that
+/// reallocated, a struct returned by value): the token moves with it, and the stored
+/// pointer still names the old address. Registering from a live borrow just before the
+/// pass that uses it, as every app's rebuild does, is what keeps that case sound until
+/// the registry owns its widgets.
+///
+/// A clone is a different widget at a different address, so it gets a fresh token —
+/// not a share of the original's, which would keep a dropped original "alive".
+///
+/// [`UiContext`]: crate::context::UiContext
+/// [`Weak`]: std::sync::Weak
+#[derive(Debug)]
+pub(crate) struct Liveness(std::sync::Arc<()>);
+
+impl Liveness {
+    pub(crate) fn new() -> Self {
+        Liveness(std::sync::Arc::new(()))
+    }
+
+    /// A watch that reports whether this token still exists.
+    pub(crate) fn watch(&self) -> std::sync::Weak<()> {
+        std::sync::Arc::downgrade(&self.0)
+    }
+}
+
+impl Clone for Liveness {
+    fn clone(&self) -> Self {
+        Liveness::new()
+    }
 }
 
 impl Widget {
@@ -1972,6 +2016,7 @@ impl Widget {
             dirty: true,
             config_file: None,
             config_key: None,
+            live: Liveness::new(),
         }
     }
 
@@ -1990,6 +2035,7 @@ impl Widget {
             dirty: true,
             config_file: None,
             config_key: None,
+            live: Liveness::new(),
         }
     }
 
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 30f614f..126eac7 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -370,7 +370,8 @@ impl EventCtx<'_> {
     /// `on_event`, not after it. No-op outside a routed path (no ctx or no self pointer).
     pub fn open_context_menu(&mut self, px: f32, py: f32) {
         if let (Some(ptr), Some(ui)) = (self.self_ptr, self.ui.as_deref_mut()) {
-            ui.handle_right_click(ptr, px, py);
+            // SAFETY: `self_ptr` is the routed widget's own adapter, live for the event.
+            unsafe { ui.handle_right_click(ptr, px, py) };
         }
     }
 
@@ -902,26 +903,6 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
         }
     }
 
-    /// Register + link a child under this widget (off `WidgetHost` in 6bd batch 4; dyn callers
-    /// went to `focus::link_parent_child`/tree ops).
-    pub fn add_child(&mut self, child: *mut (dyn WidgetHost + 'static), ctx: &mut UiContext) {
-        // The old WidgetHost default's tree link…
-        let c_id = unsafe { (*child).base().id() };
-        let p_id = self.base.id();
-        let self_ptr = self.as_ptr();
-        ctx.register_widget(p_id, self_ptr);
-        ctx.register_widget(c_id, child);
-        ctx.tree.link(p_id, c_id);
-        // …plus, for containers, the legacy container extra: parent the child back (Layer,
-        // Switcher) — the symmetric tree link the child's own set_parent used to make.
-        if Layout::has_container_children(&self.inner) {
-            let self_ptr = self.as_ptr_mut();
-            ctx.register_widget(self.base.id(), self_ptr);
-            ctx.register_widget(c_id, child);
-            ctx.tree.set_parent(c_id, Some(self.base.id()));
-        }
-    }
-
     /// Register + (un)link this widget under a parent (off `WidgetHost` in 6bd batch 4).
     pub fn set_parent(&mut self, parent: Option<*mut (dyn WidgetHost + 'static)>, ctx: &mut UiContext) {
         // Replica of the old WidgetHost default: symmetric tree link.
@@ -929,7 +910,7 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
         if let Some(p_ptr) = parent {
             let p_id = unsafe { (*p_ptr).base().id() };
             ctx.register_widget(p_id, p_ptr);
-            let self_ptr = self.as_ptr();
+            let self_ptr = self.as_ptr_mut();
             ctx.register_widget(id, self_ptr);
             ctx.tree.set_parent(id, Some(p_id));
         } else {
@@ -1640,7 +1621,8 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
                 ..
             } if Input::opens_context_menu(&self.inner) => {
                 if self.hit_test(*px, *py, ctx) {
-                    ctx.handle_right_click(self_ptr, *px, *py);
+                    // SAFETY: `self_ptr` is this adapter, derived from `&mut self` above.
+                    unsafe { ctx.handle_right_click(self_ptr, *px, *py) };
                     return true;
                 }
                 false