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

commita0e43099d1489d6b715bad5fecb48b1a5bd4728e
parenta6c15df71b
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 13:13
fix(registry): the widget registry's aliasing is sound, and checked by Miri

- Owned held its widget in a Box, a unique pointer: every app reborrow
  (self.button.take_click()) invalidated the raw pointer the registry had
  taken, so its next dispatch was UB every frame in every app (Miri:
  "trying to retag ... that tag does not exist in the borrow stack"). The
  allocation is now a raw NonNull root the app's Deref and the registry both
  derive from.
- WidgetTree::register keeps a live Owned root against a pointer to the same
  address taken from inside the widget (its context menu, its focus,
  set_parent) -- a reborrow the next app access invalidates. Compared by
  address, never read through.
- UiContext::claim_focus: a widget focusing itself mid-event is recorded
  without FocusIn re-entering it through the registry (the tree list did);
  EventCtx::request_focus uses it.
- The tree list's and paginator's embedded children are Owned fields.

CI gains a miri job: the widget::owned tests under Stacked and Tree Borrows.
On a throwaway branch the Box-based Owned fails it; this passes it, with every
other job.

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

 .github/workflows/ci.yml          |  30 +++++
 CLAUDE.md                         |  41 ++++++-
 src/context.rs                    |  16 +++
 src/scene/tree.rs                 |  29 ++++-
 src/widget/container/paginator.rs |   8 +-
 src/widget/container/treelist.rs  |  43 +++++---
 src/widget/model.rs               |   9 +-
 src/widget/owned.rs               | 226 ++++++++++++++++++++++++++------------
 8 files changed, 294 insertions(+), 108 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 398e765..820b0b1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -92,6 +92,36 @@ jobs:
       - uses: Swatinem/rust-cache@v2
       - run: cargo clippy --all-targets --all-features -- -D warnings
 
+  # The registry's pointers into app-owned widgets, under Miri: the aliasing rules a
+  # compiler does not check. Narrow on purpose (Miri interprets, slowly): the `Owned`
+  # tests, which take turns between the app's access and the registry's, under both of
+  # Miri's aliasing models. Until 2026-10-08 `Owned` held its widget in a `Box`, and every
+  # app access invalidated the registry's pointer.
+  miri:
+    runs-on: ubuntu-24.04
+    timeout-minutes: 45
+    env:
+      # A nightly may warn where stable does not; this job is about aliasing, not lints.
+      RUSTFLAGS: ""
+      MIRIFLAGS: -Zmiri-disable-isolation
+    steps:
+      - uses: actions/checkout@v5
+      - name: Install system packages
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y --no-install-recommends pkg-config libwayland-dev libxkbcommon-dev
+      - uses: dtolnay/rust-toolchain@nightly
+        with:
+          components: miri, rust-src
+      - uses: Swatinem/rust-cache@v2
+      - run: cargo miri setup
+      - name: Stacked Borrows
+        run: cargo miri test --lib widget::owned
+      - name: Tree Borrows
+        run: cargo miri test --lib widget::owned
+        env:
+          MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-tree-borrows
+
   # The browser half (src/web, the wasm examples) is cfg'd out of the Linux
   # build above, so nothing else compiles it. scripts/check-wasm type-checks
   # the library, its features and the four wasm examples.
diff --git a/CLAUDE.md b/CLAUDE.md
index 3837db1..3b10d4e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -484,7 +484,7 @@ following the caret through every step, each `done`'s serial equal to the commit
 Sway routes text-input focus only while an input method is bound, so with none (the
 24-step harness) nothing changes: 0 px.
 
-CI (`.github/workflows/ci.yml`, every push and PR) has four jobs, warnings as errors in each:
+CI (`.github/workflows/ci.yml`, every push and PR) has five jobs, warnings as errors in the first four:
 
 - **`test`** (Ubuntu 24.04) builds and tests with default and with all features. It installs
   `libwayland-dev` and `libxkbcommon-dev` (the two native libraries the build links, through
@@ -501,6 +501,9 @@ CI (`.github/workflows/ci.yml`, every push and PR) has four jobs, warnings as er
   loops, `new` without `Default`); anything else clippy reports is fixed, not allowed —
   locally, `cargo clippy -p cce-ui --all-features --all-targets -- -D warnings` is the check.
   Linux only: `src/web` and `src/mac` are compiled out there.
+- **`miri`** runs the `widget::owned` tests under Miri, Stacked and Tree Borrows (nightly):
+  the app's access to a widget and the registry's taking turns (see "The registry holds
+  pointers, and knows when they die").
 - **`wasm`** runs `scripts/check-wasm`: the library, its features and the four wasm examples,
   type-checked for the browser. Its `RUSTFLAGS` carries `--cfg=web_sys_unstable_apis` itself,
   since an environment `RUSTFLAGS` replaces `.cargo/config.toml`'s. Its first run (2026-10-08)
@@ -1721,13 +1724,39 @@ The pointer-taking entry points say so:
   whose trait objects are not `'static`, and tests that exercise raw pointers.
 - The demo's `register_roots` is the pattern to copy.
 
-What is still open is the ALIASING model. The registry derefs its pointers while the app holds
-the `Owned`, so a `&mut` reached through the registry and one reached through the field can
-coexist. The end state for that is a registry that owns its widgets and lends them out.
+**The aliasing is sound since 2026-10-08, and checked by Miri.** Three things were not:
+
+- **`Owned` held its widget in a `Box`.** A `Box` is a unique pointer to the language, so every
+  reborrow of the widget through it — each `self.button.take_click()` — invalidated the raw
+  pointer the registry had taken, and the registry's next dispatch was undefined behaviour,
+  every frame in every app. Miri reported it ("trying to retag … but that tag does not exist in
+  the borrow stack", at the registry's write). `Owned` now holds the allocation as a raw
+  `NonNull` ROOT, which the app's `Deref` and the registry both derive their references from.
+- **A registration from inside a widget replaced that root.** Opening a context menu
+  (`show_context_menu`), focusing by pointer (`set_focused_ptr`), `set_parent` and the like
+  re-registered the widget with a pointer taken from its own `&mut self` — a reborrow the next
+  app access invalidates. `WidgetTree::register` now KEEPS a live `Owned` root against a
+  pointer to the same address (compared by address, never read through); a widget swapped out
+  of its box is elsewhere and registers as usual.
+- **A widget focused itself through the registry mid-event.** The tree list's click handler
+  called `set_focused_ptr` on itself, and the context delivered FocusIn back through the
+  registry — a second `&mut` while its own was live. `UiContext::claim_focus` records the
+  focus and tells the old holder without re-entering the claimant (what
+  `EventCtx::request_focus` does too); the widget sets its own focused state.
+
+The toolkit's embedded children (the tree list's search box, add-key button and popover,
+inline editor; the paginator's menu) are `Owned` fields now, so they have roots of their own.
+The rule left is the ordinary one: a `&mut` reached through the registry must not overlap one
+taken through the `Owned` — an app does not hold `&mut self.x` across a `UiContext` call that
+reaches `x`, and a `UiContext` call handed the widget uses what it was handed. The end state
+that makes even that the compiler's job is a registry that owns its widgets and lends them out
+by handle; it would touch every widget access in every app.
 
 `a_dropped_widget_is_never_handed_out`, `a_clone_has_a_liveness_of_its_own`,
-`an_owned_widget_survives_its_vec_reallocating` and
-`swapping_the_widget_out_of_its_box_never_leaves_a_dangling_entry` are the tests.
+`an_owned_widget_survives_its_vec_reallocating`,
+`swapping_the_widget_out_of_its_box_never_leaves_a_dangling_entry`,
+`an_app_and_the_registry_take_turns_soundly` and `an_inner_registration_keeps_the_root` are
+the tests; CI's `miri` job runs the `widget::owned` ones under Stacked and Tree Borrows.
 
 **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
diff --git a/src/context.rs b/src/context.rs
index 00e42dd..5453696 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -651,6 +651,22 @@ impl UiContext {
         false
     }
 
+    /// Make `id` the window's focus from INSIDE that widget's own event handling: recorded,
+    /// and the holder before told (`unfocus`), but no FocusIn delivered back to `id` —
+    /// it is running, holding `&mut self`, and a FocusIn through the registry would be a
+    /// second `&mut` to it while the first is live. What a widget that focuses itself does
+    /// (`EventCtx::request_focus`; a tree list on a click into its rows), setting whatever
+    /// its FocusIn would have set itself.
+    pub fn claim_focus(&mut self, id: WidgetId) {
+        if let Some(old) = self.focused_widget.filter(|old| *old != id) {
+            if let Some(ptr) = self.tree.get_ptr(old) {
+                // SAFETY: a registry-resolved live widget other than the claimant.
+                unsafe { (*ptr).unfocus() };
+            }
+        }
+        self.focused_widget = Some(id);
+    }
+
     /// Focus `w` as a direct `w.focus()` did — the widget is told (`focus`), the holder before
     /// it is told it lost focus (`unfocus`) — and record it as the window's focus, which a
     /// direct call never did: the Tab walk and the accessibility tree read the record, and
diff --git a/src/scene/tree.rs b/src/scene/tree.rs
index 9f150a9..859224a 100644
--- a/src/scene/tree.rs
+++ b/src/scene/tree.rs
@@ -51,6 +51,9 @@ struct Entry {
     id: WidgetId,
     ptr: Option<*mut (dyn WidgetHost + 'static)>,
     alive: Option<Weak<()>>,
+    /// The pointer is an `Owned` box's raw root (`WidgetHost::stable_target`), which every
+    /// later access to the widget — the app's and the registry's — derives from.
+    stable: bool,
 }
 
 /// Resolve an entry's pointer to a usable one: non-null (skipping link-only and null-data
@@ -99,7 +102,7 @@ impl WidgetTree {
                 return node;
             }
         }
-        let node = self.arena.insert(Entry { id, ptr: None, alive: None });
+        let node = self.arena.insert(Entry { id, ptr: None, alive: None, stable: false });
         self.by_id.insert(id, node);
         node
     }
@@ -114,13 +117,28 @@ impl WidgetTree {
     /// 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)) {
+        // A live `Owned` root already registered for this widget is KEPT against a pointer to
+        // the same address taken some other way — a widget's own `&mut self` mid-event (its
+        // context menu, its focus), the inner widget handed for the `Owned`: that pointer is a
+        // reborrow the next access to the widget invalidates, and the root is what stays valid
+        // (`widget::Owned`). Compared by address, without reading through either pointer,
+        // which would disturb the borrow of a widget that is handling an event. A widget moved
+        // out of its box is at another address and registers as usual.
+        if let Some(node) = self.by_id.get(&id).copied() {
+            if let Some(e) = self.arena.value(node) {
+                let same = e.ptr.is_some_and(|p| std::ptr::addr_eq(p, ptr));
+                if e.stable && same && e.alive.as_ref().is_some_and(|w| w.strong_count() > 0) {
+                    return;
+                }
+            }
+        }
         // SAFETY: the caller's contract — null, or a live widget. A widget in an `Owned` box
         // names the boxed widget and the allocation's token instead of itself.
-        let (ptr, alive) = match unsafe { ptr.as_mut() } {
-            None => (ptr, None),
+        let (ptr, alive, stable) = match unsafe { ptr.as_mut() } {
+            None => (ptr, None, false),
             Some(w) => match w.stable_target() {
-                Some((inner, alive)) => (inner, Some(alive)),
-                None => (ptr, Some(w.base().live.watch())),
+                Some((inner, alive)) => (inner, Some(alive), true),
+                None => (ptr, Some(w.base().live.watch()), false),
             },
         };
         let node = self.ensure_node(id);
@@ -128,6 +146,7 @@ impl WidgetTree {
         let entry = self.arena.value_mut(node).unwrap();
         entry.ptr = Some(ptr);
         entry.alive = alive;
+        entry.stable = stable;
     }
 
     /// Make `child` a child of `parent` (deduped, reparenting from any previous parent). Mirrors
diff --git a/src/widget/container/paginator.rs b/src/widget/container/paginator.rs
index de6de82..8a1fba3 100644
--- a/src/widget/container/paginator.rs
+++ b/src/widget/container/paginator.rs
@@ -25,7 +25,9 @@ use crate::widget::{
 };
 
 pub struct Paginator {
-    pub sidebar_menu: Adapted<ButtonStrip>,
+    /// In an [`Owned`](crate::widget::Owned) box of its own, so the registry points at a
+    /// stable root rather than into this widget (see `widget::Owned`).
+    pub sidebar_menu: crate::widget::Owned<Adapted<ButtonStrip>>,
     pub selected_page: usize,
     pub sidebar_w: f32,
     pub page_labels: Vec<String>,
@@ -38,7 +40,7 @@ impl Paginator {
         let num_pages = pages.len();
 
         let temp_paginator = Paginator {
-            sidebar_menu: Adapted::new(ButtonStrip::new(0.0, 0.0, 0.0, 0.0)),
+            sidebar_menu: Adapted::new(ButtonStrip::new(0.0, 0.0, 0.0, 0.0)).into(),
             selected_page: 0,
             sidebar_w: 0.0,
             page_labels: pages.clone(),
@@ -57,7 +59,7 @@ impl Paginator {
         }
 
         Adapted::new(Paginator {
-            sidebar_menu,
+            sidebar_menu: sidebar_menu.into(),
             selected_page: 0,
             sidebar_w,
             page_labels: pages,
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index ecdfdaa..0f71937 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -164,10 +164,16 @@ fn build_tree(
 pub struct TreeList {
     pub base: Widget,
     pub scroll_box: ScrollBox,
-    pub search_box: crate::widget::Adapted<TextBox>,
-    pub add_key_btn: crate::widget::Adapted<Button>,
+    /// In an [`Owned`](crate::widget::Owned) box of its own, so the registry points at a
+    /// stable root rather than into this widget (see `widget::Owned`).
+    pub search_box: crate::widget::Owned<crate::widget::Adapted<TextBox>>,
+    /// In an [`Owned`](crate::widget::Owned) box of its own, so the registry points at a
+    /// stable root rather than into this widget (see `widget::Owned`).
+    pub add_key_btn: crate::widget::Owned<crate::widget::Adapted<Button>>,
     pub add_key_popover_open: bool,
-    pub add_key_popover_box: crate::widget::Adapted<TextBox>,
+    /// In an [`Owned`](crate::widget::Owned) box of its own, so the registry points at a
+    /// stable root rather than into this widget (see `widget::Owned`).
+    pub add_key_popover_box: crate::widget::Owned<crate::widget::Adapted<TextBox>>,
     pub new_key_path_request: Option<String>,
     pub flat_keys: Vec<(String, serde_json::Value)>,
     pub annotations: Vec<Option<String>>,
@@ -186,7 +192,9 @@ pub struct TreeList {
     /// tree while still being, visually, part of the tree pane.
     pub focused: bool,
     pub deleted_key_path: Option<String>,
-    pub edit_box: crate::widget::Adapted<TextBox>,
+    /// In an [`Owned`](crate::widget::Owned) box of its own, so the registry points at a
+    /// stable root rather than into this widget (see `widget::Owned`).
+    pub edit_box: crate::widget::Owned<crate::widget::Adapted<TextBox>>,
     pub editing_key_idx: Option<usize>,
     pub double_click_timer: Option<(web_time::Instant, usize)>,
     pub rename_request: Option<(String, String)>,
@@ -202,10 +210,10 @@ impl TreeList {
         Adapted::new(TreeList {
             base: Widget::new(),
             scroll_box,
-            search_box: TextBox::new(String::new()).with_search().with_update_on_type(true),
-            add_key_btn: Button::new(0.0, 0.0, 80.0, 26.0).with_label("+ Add Key"),
+            search_box: TextBox::new(String::new()).with_search().with_update_on_type(true).into(),
+            add_key_btn: Button::new(0.0, 0.0, 80.0, 26.0).with_label("+ Add Key").into(),
             add_key_popover_open: false,
-            add_key_popover_box: TextBox::new(String::new()).with_placeholder("new.key.path").with_multiline(false),
+            add_key_popover_box: TextBox::new(String::new()).with_placeholder("new.key.path").with_multiline(false).into(),
             new_key_path_request: None,
             flat_keys: Vec::new(),
             annotations: Vec::new(),
@@ -219,7 +227,7 @@ impl TreeList {
             last_scroll_y: 0.0,
             focused: false,
             deleted_key_path: None,
-            edit_box: TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true),
+            edit_box: TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).into(),
             editing_key_idx: None,
             double_click_timer: None,
             rename_request: None,
@@ -454,8 +462,10 @@ 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 {
-                // 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) }; }
+                // Focus claimed from inside this event (`UiContext::claim_focus`): a FocusIn
+                // through the registry would re-enter this widget while it holds `&mut self`.
+                ui.claim_focus(host_id);
+                self.focused = true;
                 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() {
@@ -484,7 +494,7 @@ impl TreeList {
                             TreeElement::Leaf { path, name, .. } => (path.clone(), name.clone()),
                         };
                         self.editing_key_idx = Some(row_idx);
-                        self.edit_box = TextBox::new(relative_name).with_multiline(false).with_draw_bg_border(true);
+                        self.edit_box = TextBox::new(relative_name).with_multiline(false).with_draw_bg_border(true).into();
                         self.edit_box.editing = true;
                         self.edit_box.cursor_idx = self.edit_box.text.chars().count();
                         self.edit_box.select_anchor = Some(0);
@@ -531,8 +541,10 @@ impl TreeList {
 
         if button == MouseButton::Right && state == ElementState::Pressed && !covered
             && px >= list_left && px <= list_left + list_width && py >= list_top && py <= list_bottom {
-                // 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) }; }
+                // Focus claimed from inside this event (`UiContext::claim_focus`): a FocusIn
+                // through the registry would re-enter this widget while it holds `&mut self`.
+                ui.claim_focus(host_id);
+                self.focused = true;
                 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() {
@@ -1094,7 +1106,6 @@ impl Input for TreeList {
     /// search box, positions/commits the inline rename editor (re-targeting focus to the
     /// adapter on commit), and runs the scrollbar activity fade.
     fn tick_ctx(&mut self, dt: f32, ectx: &mut EventCtx) -> bool {
-        let host = ectx.host_ptr();
         let host_id = ectx.id;
         let Some(ui) = ectx.ui.as_deref_mut() else {
             return false;
@@ -1168,8 +1179,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);
-                // 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) }; }
+                ui.claim_focus(host_id);
+                self.focused = true;
                 changed = true;
             }
         }
diff --git a/src/widget/model.rs b/src/widget/model.rs
index eb30922..bd1f03f 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -354,14 +354,9 @@ impl EventCtx<'_> {
     /// `focus()` / `unfocus()`, whose caller is the context) there is nothing to record.
     pub fn request_focus(&mut self) {
         let id = self.id;
-        let Some(ui) = self.ui.as_deref_mut() else { return };
-        if let Some(old) = ui.focused_widget.filter(|old| *old != id) {
-            if let Some(ptr) = ui.tree.get_ptr(old) {
-                // SAFETY: a registry-resolved live widget other than this one.
-                unsafe { (*ptr).unfocus() };
-            }
+        if let Some(ui) = self.ui.as_deref_mut() {
+            ui.claim_focus(id);
         }
-        ui.focused_widget = Some(id);
     }
 
     /// Drop this widget's hold on the window's focus, if it has it (MenuBar releases focus
diff --git a/src/widget/owned.rs b/src/widget/owned.rs
index f55f524..551b889 100644
--- a/src/widget/owned.rs
+++ b/src/widget/owned.rs
@@ -30,7 +30,9 @@
 //! app.search.set_text("x");                           // Deref: the widget's own methods
 //! ```
 
+use std::mem::ManuallyDrop;
 use std::ops::{Deref, DerefMut};
+use std::ptr::NonNull;
 
 use super::core::Liveness;
 use super::{ContextAction, CornerRadii, Event, FocusRole, LayoutConstraints, Point, Size, Widget, WidgetHost, WidgetId};
@@ -38,36 +40,72 @@ use crate::context::UiContext;
 
 /// A widget in a heap allocation of its own, which the registry can point at however the
 /// `Owned` is moved. See the module docs.
+///
+/// **The allocation is held as a raw pointer, not a `Box`** (since 2026-10-08). A `Box` is a
+/// unique pointer to the language: every reborrow of the widget through it — each
+/// `self.button.take_click()` an app makes — is a write through that unique pointer, which
+/// under Rust's aliasing model invalidates every raw pointer taken from an earlier borrow.
+/// The registry's pointer was one, so the next dispatch through it was undefined behaviour,
+/// every frame in every app (a compiler does not exploit it today; Miri reports it). Now the
+/// app's `Deref` and the registry both derive their references from the same raw root, and
+/// neither invalidates the other; what remains is the rule every `&mut` already obeys — one
+/// reached through the registry must not overlap one taken through the `Owned`, which a
+/// `UiContext` call that is handed the widget honours by re-deriving its pointer from what
+/// it is handed (`UiContext::set_focused`). `an_app_and_the_registry_take_turns_soundly` is
+/// the check, run under Miri in CI.
 pub struct Owned<W: WidgetHost + 'static> {
-    // Declared first so it is dropped first: the registry stops resolving the widget before
-    // the widget's own `Drop` runs and the allocation is freed.
-    live: Liveness,
-    widget: Box<W>,
+    // Dropped in `Drop` before the allocation is freed: the registry stops resolving the
+    // widget before the widget's own `Drop` runs.
+    live: ManuallyDrop<Liveness>,
+    widget: NonNull<W>,
+    _owns: std::marker::PhantomData<W>,
 }
 
+// What a `Box<W>` is: the `Owned` owns its `W` outright.
+unsafe impl<W: WidgetHost + Send + 'static> Send for Owned<W> {}
+unsafe impl<W: WidgetHost + Sync + 'static> Sync for Owned<W> {}
+
 impl<W: WidgetHost + 'static> Owned<W> {
     pub fn new(widget: W) -> Self {
-        Owned { live: Liveness::new(), widget: Box::new(widget) }
+        let widget = Box::into_non_null(Box::new(widget));
+        Owned { live: ManuallyDrop::new(Liveness::new()), widget, _owns: std::marker::PhantomData }
     }
 
     /// The widget, by value; the allocation and its token go with the `Owned`.
     pub fn into_inner(self) -> W {
-        let Owned { live, widget } = self;
-        drop(live);
-        *widget
+        let mut this = ManuallyDrop::new(self);
+        // SAFETY: `this` is never used or dropped again; the token is dropped (and the
+        // registry stops resolving the widget) before the allocation is taken back.
+        unsafe {
+            ManuallyDrop::drop(&mut this.live);
+            *Box::from_raw(this.widget.as_ptr())
+        }
+    }
+}
+
+impl<W: WidgetHost + 'static> Drop for Owned<W> {
+    fn drop(&mut self) {
+        // SAFETY: dropped exactly once, here; the allocation was `Box`-made in `new` and is
+        // freed only here, after the token that lets the registry resolve it is gone.
+        unsafe {
+            ManuallyDrop::drop(&mut self.live);
+            drop(Box::from_raw(self.widget.as_ptr()));
+        }
     }
 }
 
 impl<W: WidgetHost + 'static> Deref for Owned<W> {
     type Target = W;
     fn deref(&self) -> &W {
-        &self.widget
+        // SAFETY: the allocation is live while `self` is, and holds a valid `W`.
+        unsafe { self.widget.as_ref() }
     }
 }
 
 impl<W: WidgetHost + 'static> DerefMut for Owned<W> {
     fn deref_mut(&mut self) -> &mut W {
-        &mut self.widget
+        // SAFETY: as in `deref`; `&mut self` is the app's exclusive access.
+        unsafe { self.widget.as_mut() }
     }
 }
 
@@ -80,7 +118,7 @@ impl<W: WidgetHost + 'static> From<W> for Owned<W> {
 /// A clone is a different widget in a different allocation, with a token of its own.
 impl<W: WidgetHost + Clone + 'static> Clone for Owned<W> {
     fn clone(&self) -> Self {
-        Owned::new((*self.widget).clone())
+        Owned::new((**self).clone())
     }
 }
 
@@ -92,7 +130,7 @@ impl<W: WidgetHost + Default + 'static> Default for Owned<W> {
 
 impl<W: WidgetHost + std::fmt::Debug + 'static> std::fmt::Debug for Owned<W> {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.debug_tuple("Owned").field(&*self.widget).finish()
+        f.debug_tuple("Owned").field(&**self).finish()
     }
 }
 
@@ -100,68 +138,69 @@ impl<W: WidgetHost + std::fmt::Debug + 'static> std::fmt::Debug for Owned<W> {
 /// the one addition is [`stable_target`](WidgetHost::stable_target).
 impl<W: WidgetHost + 'static> WidgetHost for Owned<W> {
     fn stable_target(&mut self) -> Option<(*mut (dyn WidgetHost + 'static), std::sync::Weak<()>)> {
-        let ptr: *mut (dyn WidgetHost + 'static) = &mut *self.widget as *mut W;
+        // The raw root itself, not a pointer taken from a reborrow (see the type's docs).
+        let ptr: *mut (dyn WidgetHost + 'static) = self.widget.as_ptr();
         Some((ptr, self.live.watch()))
     }
 
-    fn base(&self) -> &Widget { self.widget.base() }
-    fn base_mut(&mut self) -> &mut Widget { self.widget.base_mut() }
-    fn preferred_height(&self) -> Option<f32> { self.widget.preferred_height() }
-    fn label_strip(&self) -> f32 { self.widget.label_strip() }
-    fn detached_label_rect(&self) -> Option<crate::scene::layout::Rect> { self.widget.detached_label_rect() }
-    fn mark_dirty(&mut self, ctx: &mut UiContext) { self.widget.mark_dirty(ctx) }
-    fn as_any(&self) -> &dyn std::any::Any { self.widget.as_any() }
-    fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self.widget.as_any_mut() }
-    fn handle_event(&mut self, event: &Event, ctx: &mut UiContext) -> bool { self.widget.handle_event(event, ctx) }
-    fn measure(&self, constraints: LayoutConstraints, ctx: &UiContext) -> Size { self.widget.measure(constraints, ctx) }
-    fn layout(&mut self, origin: Point, constraints: LayoutConstraints, ctx: &mut UiContext) { self.widget.layout(origin, constraints, ctx) }
-    fn rect(&self) -> (f32, f32, f32, f32) { self.widget.rect() }
-    fn label(&self) -> Option<String> { self.widget.label() }
-    fn context_action(&mut self, action: ContextAction) -> bool { self.widget.context_action(action) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.widget.set_rect(x, y, w, h) }
-    fn set_row_rect(&mut self, x: f32, w: f32) { self.widget.set_row_rect(x, w) }
-    fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool { self.widget.hit_test(px, py, ctx) }
-    fn highlight_quad(&self, ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])> { self.widget.highlight_quad(ctx) }
-    fn color(&self) -> [f32; 4] { self.widget.color() }
-    fn solid_border(&self) -> Option<([f32; 4], f32)> { self.widget.solid_border() }
-    fn plate_bevel(&self) -> Option<f32> { self.widget.plate_bevel() }
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> { self.widget.extra_quads() }
-    fn extra_arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> { self.widget.extra_arcs() }
-    fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> { self.widget.extra_circles() }
-    fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> { self.widget.all_quads(ctx) }
-    fn paint_self(&self, ui: &UiContext, ctx: &mut crate::scene::paint::PaintCtx) { self.widget.paint_self(ui, ctx) }
-    fn clips_children(&self) -> bool { self.widget.clips_children() }
-    fn renders_own_subtree(&self) -> bool { self.widget.renders_own_subtree() }
+    fn base(&self) -> &Widget { (**self).base() }
+    fn base_mut(&mut self) -> &mut Widget { (**self).base_mut() }
+    fn preferred_height(&self) -> Option<f32> { (**self).preferred_height() }
+    fn label_strip(&self) -> f32 { (**self).label_strip() }
+    fn detached_label_rect(&self) -> Option<crate::scene::layout::Rect> { (**self).detached_label_rect() }
+    fn mark_dirty(&mut self, ctx: &mut UiContext) { (**self).mark_dirty(ctx) }
+    fn as_any(&self) -> &dyn std::any::Any { (**self).as_any() }
+    fn as_any_mut(&mut self) -> &mut dyn std::any::Any { (**self).as_any_mut() }
+    fn handle_event(&mut self, event: &Event, ctx: &mut UiContext) -> bool { (**self).handle_event(event, ctx) }
+    fn measure(&self, constraints: LayoutConstraints, ctx: &UiContext) -> Size { (**self).measure(constraints, ctx) }
+    fn layout(&mut self, origin: Point, constraints: LayoutConstraints, ctx: &mut UiContext) { (**self).layout(origin, constraints, ctx) }
+    fn rect(&self) -> (f32, f32, f32, f32) { (**self).rect() }
+    fn label(&self) -> Option<String> { (**self).label() }
+    fn context_action(&mut self, action: ContextAction) -> bool { (**self).context_action(action) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { (**self).set_rect(x, y, w, h) }
+    fn set_row_rect(&mut self, x: f32, w: f32) { (**self).set_row_rect(x, w) }
+    fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool { (**self).hit_test(px, py, ctx) }
+    fn highlight_quad(&self, ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])> { (**self).highlight_quad(ctx) }
+    fn color(&self) -> [f32; 4] { (**self).color() }
+    fn solid_border(&self) -> Option<([f32; 4], f32)> { (**self).solid_border() }
+    fn plate_bevel(&self) -> Option<f32> { (**self).plate_bevel() }
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> { (**self).extra_quads() }
+    fn extra_arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> { (**self).extra_arcs() }
+    fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> { (**self).extra_circles() }
+    fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> { (**self).all_quads(ctx) }
+    fn paint_self(&self, ui: &UiContext, ctx: &mut crate::scene::paint::PaintCtx) { (**self).paint_self(ui, ctx) }
+    fn clips_children(&self) -> bool { (**self).clips_children() }
+    fn renders_own_subtree(&self) -> bool { (**self).renders_own_subtree() }
     fn all_rounded_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
-        self.widget.all_rounded_quads(ctx)
-    }
-    fn widget_font(&self) -> Option<String> { self.widget.widget_font() }
-    fn type_name(&self) -> &'static str { self.widget.type_name() }
-    fn popover_rect(&self) -> Option<(f32, f32, f32, f32)> { self.widget.popover_rect() }
-    fn render_popover(&self, pc: &mut dyn crate::layout::RenderTarget) { self.widget.render_popover(pc) }
-    fn focus(&mut self) { self.widget.focus() }
-    fn unfocus(&mut self) { self.widget.unfocus() }
-    fn focused(&self, ctx: &UiContext) -> bool { self.widget.focused(ctx) }
-    fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem) { self.widget.prepare_text(fs) }
-    fn set_visible(&mut self, visible: bool) { self.widget.set_visible(visible) }
-    fn visible(&self) -> bool { self.widget.visible() }
-    fn tick(&mut self, dt: f32, ctx: &mut UiContext) -> bool { self.widget.tick(dt, ctx) }
-    fn wants_tick(&self) -> bool { self.widget.wants_tick() }
-    fn is_child_visible(&self, child_id: WidgetId) -> bool { self.widget.is_child_visible(child_id) }
-    fn set_modifiers(&mut self, ctrl: bool, shift: bool, alt: bool) { self.widget.set_modifiers(ctrl, shift, alt) }
-    fn z_index(&self) -> i32 { self.widget.z_index() }
-    fn is_scrollable(&self) -> bool { self.widget.is_scrollable() }
-    fn blocks_root_plate_drag(&self) -> bool { self.widget.blocks_root_plate_drag() }
-    fn corner_style(&self) -> (f32, (bool, bool, bool, bool)) { self.widget.corner_style() }
-    fn focus_role(&self) -> FocusRole { self.widget.focus_role() }
-    fn keeps_tab(&self) -> bool { self.widget.keeps_tab() }
-    fn a11y_role(&self) -> Option<accesskit::Role> { self.widget.a11y_role() }
-    fn a11y_value(&self) -> Option<String> { self.widget.a11y_value() }
-    fn a11y_range(&self) -> Option<(f64, f64, f64)> { self.widget.a11y_range() }
-    fn a11y_set_value(&mut self, value: f64) -> bool { self.widget.a11y_set_value(value) }
-    fn a11y_items(&self) -> Vec<crate::a11y::A11yItem> { self.widget.a11y_items() }
-    fn a11y_select_item(&mut self, idx: usize) -> bool { self.widget.a11y_select_item(idx) }
-    fn corner_radii(&self) -> CornerRadii { self.widget.corner_radii() }
+        (**self).all_rounded_quads(ctx)
+    }
+    fn widget_font(&self) -> Option<String> { (**self).widget_font() }
+    fn type_name(&self) -> &'static str { (**self).type_name() }
+    fn popover_rect(&self) -> Option<(f32, f32, f32, f32)> { (**self).popover_rect() }
+    fn render_popover(&self, pc: &mut dyn crate::layout::RenderTarget) { (**self).render_popover(pc) }
+    fn focus(&mut self) { (**self).focus() }
+    fn unfocus(&mut self) { (**self).unfocus() }
+    fn focused(&self, ctx: &UiContext) -> bool { (**self).focused(ctx) }
+    fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem) { (**self).prepare_text(fs) }
+    fn set_visible(&mut self, visible: bool) { (**self).set_visible(visible) }
+    fn visible(&self) -> bool { (**self).visible() }
+    fn tick(&mut self, dt: f32, ctx: &mut UiContext) -> bool { (**self).tick(dt, ctx) }
+    fn wants_tick(&self) -> bool { (**self).wants_tick() }
+    fn is_child_visible(&self, child_id: WidgetId) -> bool { (**self).is_child_visible(child_id) }
+    fn set_modifiers(&mut self, ctrl: bool, shift: bool, alt: bool) { (**self).set_modifiers(ctrl, shift, alt) }
+    fn z_index(&self) -> i32 { (**self).z_index() }
+    fn is_scrollable(&self) -> bool { (**self).is_scrollable() }
+    fn blocks_root_plate_drag(&self) -> bool { (**self).blocks_root_plate_drag() }
+    fn corner_style(&self) -> (f32, (bool, bool, bool, bool)) { (**self).corner_style() }
+    fn focus_role(&self) -> FocusRole { (**self).focus_role() }
+    fn keeps_tab(&self) -> bool { (**self).keeps_tab() }
+    fn a11y_role(&self) -> Option<accesskit::Role> { (**self).a11y_role() }
+    fn a11y_value(&self) -> Option<String> { (**self).a11y_value() }
+    fn a11y_range(&self) -> Option<(f64, f64, f64)> { (**self).a11y_range() }
+    fn a11y_set_value(&mut self, value: f64) -> bool { (**self).a11y_set_value(value) }
+    fn a11y_items(&self) -> Vec<crate::a11y::A11yItem> { (**self).a11y_items() }
+    fn a11y_select_item(&mut self, idx: usize) -> bool { (**self).a11y_select_item(idx) }
+    fn corner_radii(&self) -> CornerRadii { (**self).corner_radii() }
 }
 
 #[cfg(test)]
@@ -190,6 +229,51 @@ mod tests {
         Some(unsafe { (*p).as_any().downcast_ref::<Tag>().unwrap().n })
     }
 
+    /// The app and the registry take turns on one widget, as every frame of every app does:
+    /// the app changes it through the `Owned`, the registry reads and writes it through its
+    /// pointer, and back. With the widget in a `Box`, each app access invalidated the
+    /// registry's pointer under Rust's aliasing rules; run under Miri (`miri` in CI), this is
+    /// the check that neither way in invalidates the other.
+    #[test]
+    fn an_app_and_the_registry_take_turns_soundly() {
+        let mut w = tag(0);
+        let id = w.base().id();
+        let mut tree = WidgetTree::new();
+        unsafe { tree.register(id, &mut w as &mut (dyn WidgetHost + 'static)) };
+        for i in 1..=4u32 {
+            w.n += 1; // the app, through `DerefMut`
+            let p = tree.get_ptr(id).unwrap();
+            // SAFETY: the registry resolved it; no app reference is live across this.
+            unsafe { (*p).as_any_mut().downcast_mut::<Tag>().unwrap().n += 10 };
+            assert_eq!(w.n, i * 11, "the app sees what the registry wrote");
+            assert_eq!(read(&tree, id), Some(w.n), "and the registry what the app wrote");
+        }
+        let moved = w; // moving the `Owned` moves no widget
+        assert_eq!(read(&tree, id), Some(44));
+        drop(moved);
+        assert_eq!(read(&tree, id), None, "dropped: the registry no longer resolves it");
+    }
+
+    /// A registration through a pointer to the boxed widget itself — what a widget mid-event
+    /// hands over to open its context menu or take focus — keeps the box's root: that
+    /// pointer is a reborrow the next app access invalidates. The app and the registry then
+    /// still take turns soundly (under Miri, the check).
+    #[test]
+    fn an_inner_registration_keeps_the_root() {
+        let mut w = tag(1);
+        let id = w.base().id();
+        let mut tree = WidgetTree::new();
+        unsafe { tree.register(id, &mut w as &mut (dyn WidgetHost + 'static)) };
+        let root = tree.get_ptr(id).unwrap();
+        let inner: &mut Tag = &mut w;
+        unsafe { tree.register(id, inner as &mut (dyn WidgetHost + 'static)) };
+        assert!(std::ptr::addr_eq(tree.get_ptr(id).unwrap(), root), "the root is kept");
+        for i in 2..=4u32 {
+            w.n = i; // the app
+            assert_eq!(read(&tree, id), Some(i), "the registry, through the kept root");
+        }
+    }
+
     #[test]
     fn an_owned_widget_survives_its_vec_reallocating() {
         // The hole `Owned` closes: rows registered, then the Vec grows and moves them.
@@ -232,7 +316,7 @@ mod tests {
         owned.set_rect(1.0, 2.0, 3.0, 4.0);
         assert_eq!(WidgetHost::rect(&*owned), (1.0, 2.0, 3.0, 4.0));
         assert_eq!(WidgetHost::rect(&owned), (1.0, 2.0, 3.0, 4.0));
-        assert_eq!(owned.base().id(), owned.widget.base().id());
+        assert_eq!(owned.base().id(), unsafe { owned.widget.as_ref() }.base().id());
         assert!(owned.as_any().downcast_ref::<Tag>().is_some(), "as_any reaches the widget");
         let copy = owned.clone();
         let (a, _) = owned.stable_target().unwrap();