git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commitede104a95d4460f9862acb1b2f6c89ef19a61d83
parent1b38b445c7
authorLucas Galante <[email protected]>
date2026-08-14 12:57
fix: don't dispatch to a page that hasn't been laid out since the switch

A page switch takes effect immediately (`app.current_page` is reassigned in the
key/dropdown handlers), but widget registration is a side effect of the view pass
and `clear_hierarchy` wipes the registry at the top of every rebuild. So for the
one frame between the switch and the next `rebuild_layout`, none of the new page's
`section_widgets()` ids resolve, and the first event to arrive — in practice the
release half of the PageDown that caused the switch — was dropped once per root.

Measured by walking all 14 pages: drops on arrival equalled that page's
`section_widgets()` count exactly (Bluetooth 1, Browser 4, Default Apps 8,
Network 1, Notifications 3, ...). The five pages implementing
`register_extra_dispatch_roots` were incidentally immune, which is why the spam
looked arbitrary rather than systematic.

Dispatching across that gap cannot reach a widget — it only emits warnings that
would mask a real stale-root bug — so `dispatch_page_event` now returns false
until `laid_out_page` matches the current page. Same walk after the fix: 0 drops
on every page, and clicks, typing and ctrl-nav all still land.

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

 src/input_handler.rs | 10 ++++++++++
 src/main.rs          |  7 +++++++
 src/renderer.rs      |  1 +
 3 files changed, 18 insertions(+)

diff --git a/src/input_handler.rs b/src/input_handler.rs
index 281df5b..271a5ab 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -355,6 +355,16 @@ impl SystemInterface {
     /// (hover bookkeeping); other events stop at the first handler.
     pub(crate) fn dispatch_page_event(&mut self, event: &cce_ui::widget::Event) -> bool {
         use cce_ui::widget::Event;
+        // A page switch takes effect immediately, but widget registration is a side effect
+        // of the view pass and `clear_hierarchy` wipes the registry every rebuild — so
+        // until the new page has been laid out once, none of its `section_widgets()` ids
+        // resolve. Dispatching anyway can't reach a widget; it only emits one
+        // "unregistered/stale root" warning per root (the pages that implement
+        // `register_extra_dispatch_roots` were incidentally immune, which is why only 9 of
+        // the 14 spammed). Suppress across the gap instead — the rebuild is one frame away.
+        if self.laid_out_page != Some(self.app.current_page) {
+            return false;
+        }
         let is_pointer_event = matches!(
             event,
             Event::PointerMove { .. } | Event::MouseButton { .. } | Event::MouseWheel { .. }
diff --git a/src/main.rs b/src/main.rs
index f24ca46..3856531 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -87,6 +87,12 @@ struct SystemInterface {
     // (single-slot with the global widget focus — descending clears it); the per-section
     // widget groups come from AppPage::section_widgets each time they're needed.
     focused_section: Option<usize>,
+    // The page the last `rebuild_layout` actually laid out. Registration is a side effect
+    // of the view pass (`render_widget`), and `clear_hierarchy` wipes the registry each
+    // rebuild — so between a page switch and the next rebuild, the NEW page's
+    // `section_widgets()` ids are not registered and every event to them is dropped with
+    // a router warning. `dispatch_page_event` suppresses dispatch across that gap.
+    laid_out_page: Option<Page>,
     page_dropdown: cce_ui::widget::Adapted<cce_ui::widget::input::Dropdown>,
     // Switcher + Page DISSOLVED (Phase 6u): the current page is app.current_page, page
     // scroll is scroll_y/max_scroll_y, and the page scrollbar is this app-owned widget
@@ -186,6 +192,7 @@ impl cce_ui::engine::Application for SystemInterface {
             scrollable_buttons_start_idx: 0,
             last_scroll_y: 0.0,
             focused_section: None,
+            laid_out_page: None,
             page_dropdown,
             page_scroll_bar: crate::scroll_bar::ScrollBar::new(),
             content_h: 0.0,
diff --git a/src/renderer.rs b/src/renderer.rs
index ecc700b..7d04a3a 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -663,6 +663,7 @@ impl SystemInterface {
             }
         }
         self.needs_rebuild = false;
+        self.laid_out_page = Some(self.app.current_page);
         self.last_scroll_y = self.scroll_y;
         self.ui_context.clear_dirty();
     }