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

commit86bddc3aefcc3fa27eadee9d87c283657dae0998
parent078a80e2fc
authorLucas Galante <[email protected]>
date2026-08-14 13:45
feat: UiContext::unregister_widget, so rebuilt widget Vecs can drop their old ids

`WidgetId`s are globally monotonic (`NEXT_WIDGET_ID.fetch_add`) and never reused, so
an app that rebuilds a `Vec` of widgets gets NEW ids for the replacements —
re-registering does not overwrite the outgoing entries, and there was no way to
remove them. The registry kept raw pointers into the freed Vec.

That is a use-after-free, not a leak: several paths walk the whole registry and
dereference. `close_popovers_missed_by_press` runs on every left press from the
engine (`backend/window_runner.rs`), and `is_coordinate_covered` falls back to a
full scan — both do `ptr.as_ref()` (which only guards null) and then call
`visible()` / `popover_rect()` on the result.

`WidgetTree::remove` already existed with no production caller; this exposes it
through `UiContext` alongside the tick-receiver and coverage-cache bookkeeping that
`register_widget` does, so the two are symmetric. Apps that call `clear_hierarchy`
every rebuild do not need it — the wipe already drops the outgoing ids, which is why
only the four crates that never call it are affected.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/context.rs | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/src/context.rs b/src/context.rs
index ca9319d..bcde4e5 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -557,6 +557,22 @@ impl UiContext {
         }
     }
 
+    /// Drop `id`'s registration. **Apps that rebuild a `Vec` of widgets must call this for the
+    /// outgoing ids**, because `WidgetId`s are globally monotonic (`NEXT_WIDGET_ID.fetch_add`)
+    /// and are never reused: the replacements register under *new* ids, so re-registering does
+    /// not overwrite the old entries. Those keep raw pointers into the freed Vec, and several
+    /// paths walk the whole registry and dereference — `close_popovers_missed_by_press` runs on
+    /// every left press (`backend/window_runner.rs`), and `is_coordinate_covered` falls back to a
+    /// full scan — so a stale entry is a use-after-free, not just a leak.
+    ///
+    /// Apps that call [`clear_hierarchy`](Self::clear_hierarchy) every rebuild do not need this;
+    /// the wipe already drops the outgoing ids.
+    pub fn unregister_widget(&mut self, id: WidgetId) {
+        self.tree.remove(id);
+        self.unregister_tick_receiver(id);
+        self.invalidate_coverage_cache();
+    }
+
     pub fn link_ids(&mut self, parent: WidgetId, child: WidgetId) {
         self.tree.link(parent, child);
     }