app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit ff21f1ec6d7445c813153de6683e1bdb007d6afe
parent e8742848fba7e40d7aa184af70597aff2f34ad79
Author: triesap <triesap@radroots.dev>
Date:   Thu, 22 Jan 2026 00:16:34 +0000

app: stabilize ui primitives and dialog lifecycle

- add wasm-safe cleanup/state handling across dismissable, modal, scroll lock, focus
- route dialog open/present signals without disposed callbacks
- fix portal mount handling and ui-core wasm imports
- align setup EULA timestamp formatting and workspace deps

Diffstat:
MCargo.lock | 3+++
MCargo.toml | 1+
Mapp/Cargo.toml | 1+
Mapp/src/setup.rs | 6++++--
Mcrates/ui-components/src/dialog.rs | 40+++++++++++++++++++++++-----------------
Mcrates/ui-core/src/input.rs | 12++++++++++++
Mcrates/ui-primitives/Cargo.toml | 2++
Mcrates/ui-primitives/src/aria_hidden.rs | 64++++++++++++++++++++++++++++++++++++++++------------------------
Mcrates/ui-primitives/src/dismissable.rs | 33++++++++++++++++++++++++---------
Mcrates/ui-primitives/src/focus.rs | 36+++++++++++++++++++++---------------
Mcrates/ui-primitives/src/portal.rs | 10++++++++--
Mcrates/ui-primitives/src/presence.rs | 11++++++++---
Mcrates/ui-primitives/src/scroll_lock.rs | 5++++-
13 files changed, 151 insertions(+), 73 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1643,6 +1643,7 @@ name = "radroots-app" version = "0.1.0" dependencies = [ "async-trait", + "chrono", "console_error_panic_hook", "futures", "gloo-timers", @@ -1732,6 +1733,8 @@ version = "0.1.0" dependencies = [ "leptos", "radroots-app-ui-core", + "send_wrapper", + "wasm-bindgen", "web-sys", ] diff --git a/Cargo.toml b/Cargo.toml @@ -89,6 +89,7 @@ web-sys = { version = "0.3.77", features = [ wasm-bindgen-futures = "0.4" base64 = "0.22" serde-wasm-bindgen = "0.6" +send_wrapper = "0.6" rusqlite = { version = "0.31", default-features = false } url = "2" chrono = "0.4" diff --git a/app/Cargo.toml b/app/Cargo.toml @@ -34,3 +34,4 @@ async-trait.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] radroots-log = { path = "../refs/crates/log", features = ["std"] } +chrono.workspace = true diff --git a/app/src/setup.rs b/app/src/setup.rs @@ -6,12 +6,14 @@ use radroots_app_core::keystore::RadrootsClientKeystoreNostr; #[cfg(target_arch = "wasm32")] use js_sys::Date; +#[cfg(not(target_arch = "wasm32"))] +use chrono::{SecondsFormat, Utc}; + use crate::{ app_datastore_create_state, app_datastore_key_nostr_key, app_keystore_nostr_ensure_key, app_log_debug_emit, - app_state_timestamp_ms, RadrootsAppInitError, RadrootsAppInitResult, RadrootsAppKeyMapConfig, @@ -27,7 +29,7 @@ pub fn app_setup_eula_date() -> String { #[cfg(not(target_arch = "wasm32"))] pub fn app_setup_eula_date() -> String { - app_state_timestamp_ms().to_string() + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) } pub fn app_setup_state_new(active_key: String, eula_date: String) -> RadrootsAppState { diff --git a/crates/ui-components/src/dialog.rs b/crates/ui-components/src/dialog.rs @@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex}; use radroots_app_ui_core::RadrootsAppUiId; use radroots_app_ui_primitives::{ + RadrootsAppUiDismissableReason, RadrootsAppUiDismissableLayer, RadrootsAppUiFocusScope, RadrootsAppUiModalGuard, @@ -23,6 +24,7 @@ use radroots_app_ui_primitives::{ struct RadrootsAppUiDialogContext { open: Signal<bool>, set_open: Callback<bool>, + dismiss: Callback<RadrootsAppUiDismissableReason>, modal: bool, content_id: String, title_id: RwSignal<Option<String>>, @@ -49,7 +51,7 @@ pub fn RadrootsAppUiDialogRoot( let open_prop = open; let is_controlled = open_prop.is_some(); let open_signal = match open_prop { - Some(open) => Signal::derive(move || open.get()), + Some(open) => open.into(), None => open_state.into(), }; let on_open_change = on_open_change.clone(); @@ -61,6 +63,12 @@ pub fn RadrootsAppUiDialogRoot( callback.run(value); } }); + let dismiss = { + let set_open = set_open.clone(); + Callback::new(move |_reason: RadrootsAppUiDismissableReason| { + set_open.run(false); + }) + }; let content_id = RadrootsAppUiId::next().prefixed("dialog-content"); let modal = modal.unwrap_or(true); let title_id = RwSignal::new(None::<String>); @@ -68,6 +76,7 @@ pub fn RadrootsAppUiDialogRoot( provide_context(RadrootsAppUiDialogContext { open: open_signal, set_open, + dismiss, modal, content_id, title_id, @@ -116,7 +125,7 @@ pub fn RadrootsAppUiDialogTrigger( pub fn RadrootsAppUiDialogPortal(children: ChildrenFn) -> impl IntoView { let context = use_context::<RadrootsAppUiDialogContext>() .expect("dialog context"); - let present = Signal::derive(move || context.open.get()); + let present = context.open; let children = StoredValue::new(children); view! { <RadrootsAppUiPresence present=present> @@ -175,10 +184,13 @@ pub fn RadrootsAppUiDialogContent( #[cfg(target_arch = "wasm32")] { - let node_ref = node_ref.clone(); + use leptos::wasm_bindgen::JsCast; + use leptos::web_sys; + + let node_ref = node_ref; let scroll_guard = Arc::clone(&scroll_guard); let modal_guard = Arc::clone(&modal_guard); - on_mount(move || { + node_ref.on_load(move |root| { if modal { if let Ok(guard) = radroots_app_ui_scroll_lock_acquire() { let mut state = scroll_guard @@ -186,13 +198,12 @@ pub fn RadrootsAppUiDialogContent( .unwrap_or_else(|poisoned| poisoned.into_inner()); *state = Some(guard); } - if let Some(root) = node_ref.get() { - if let Ok(guard) = radroots_app_ui_modal_hide_siblings(&root) { - let mut state = modal_guard - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *state = Some(guard); - } + let element: web_sys::Element = root.unchecked_into(); + if let Ok(guard) = radroots_app_ui_modal_hide_siblings(&element) { + let mut state = modal_guard + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *state = Some(guard); } } }); @@ -211,12 +222,7 @@ pub fn RadrootsAppUiDialogContent( .take(); }); - let on_dismiss = { - let set_open = context.set_open.clone(); - Callback::new(move |_reason| { - set_open.run(false); - }) - }; + let on_dismiss = context.dismiss.clone(); let labelled_by = move || context.title_id.get(); let described_by = move || context.description_id.get(); diff --git a/crates/ui-core/src/input.rs b/crates/ui-core/src/input.rs @@ -1,6 +1,9 @@ use core::fmt; use core::sync::atomic::{AtomicU8, Ordering}; +#[cfg(target_arch = "wasm32")] +use alloc::boxed::Box; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RadrootsAppUiInputModality { Keyboard, @@ -99,20 +102,29 @@ mod tests { radroots_app_ui_input_modality_get, radroots_app_ui_input_modality_set, RadrootsAppUiInputModality, + RADROOTS_APP_UI_INPUT_MODE, }; + use core::sync::atomic::Ordering; + + fn reset_input_modality() { + RADROOTS_APP_UI_INPUT_MODE.store(0, Ordering::Relaxed); + } #[test] fn input_modality_defaults_to_none() { + reset_input_modality(); let current = radroots_app_ui_input_modality_get(); assert!(current.is_none()); } #[test] fn input_modality_set_roundtrips() { + reset_input_modality(); radroots_app_ui_input_modality_set(RadrootsAppUiInputModality::Keyboard); assert_eq!( radroots_app_ui_input_modality_get(), Some(RadrootsAppUiInputModality::Keyboard) ); + reset_input_modality(); } } diff --git a/crates/ui-primitives/Cargo.toml b/crates/ui-primitives/Cargo.toml @@ -14,6 +14,8 @@ radroots-app-ui-core = { path = "../ui-core" } leptos = { workspace = true, features = ["csr"] } [target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = { workspace = true } +send_wrapper = { workspace = true } web-sys = { workspace = true, features = [ "CssStyleDeclaration", "Document", diff --git a/crates/ui-primitives/src/aria_hidden.rs b/crates/ui-primitives/src/aria_hidden.rs @@ -1,7 +1,11 @@ +#[cfg(target_arch = "wasm32")] +use std::cell::RefCell; + +#[cfg(not(target_arch = "wasm32"))] use std::sync::{Mutex, OnceLock}; #[cfg(target_arch = "wasm32")] -use web_sys::{window, Element, HtmlCollection}; +use web_sys::{window, Element}; #[cfg(target_arch = "wasm32")] #[derive(Debug, Clone)] @@ -58,36 +62,51 @@ impl Drop for RadrootsAppUiModalGuard { } } +#[cfg(target_arch = "wasm32")] +thread_local! { + static MODAL_STATE: RefCell<ModalState> = RefCell::new(ModalState::default()); +} + +#[cfg(not(target_arch = "wasm32"))] static MODAL_STATE: OnceLock<Mutex<ModalState>> = OnceLock::new(); -fn modal_state() -> &'static Mutex<ModalState> { - MODAL_STATE.get_or_init(|| Mutex::new(ModalState::default())) +#[cfg(target_arch = "wasm32")] +fn modal_state_with<T>(f: impl FnOnce(&mut ModalState) -> T) -> T { + MODAL_STATE.with(|state| f(&mut state.borrow_mut())) +} + +#[cfg(not(target_arch = "wasm32"))] +fn modal_state_with<T>(f: impl FnOnce(&mut ModalState) -> T) -> T { + let mut state = MODAL_STATE + .get_or_init(|| Mutex::new(ModalState::default())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + f(&mut state) } pub fn radroots_app_ui_modal_hide_siblings( root: &RadrootsAppUiModalTarget, ) -> RadrootsAppUiModalResult<RadrootsAppUiModalGuard> { - let mut state = modal_state() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let id = state.next_id; - state.next_id = state.next_id.saturating_add(1); - let hidden = modal_collect_hidden(root)?; - state.layers.push(ModalLayer { id, hidden }); + let id = modal_state_with(|state| { + let id = state.next_id; + state.next_id = state.next_id.saturating_add(1); + let hidden = modal_collect_hidden(root)?; + state.layers.push(ModalLayer { id, hidden }); + Ok(id) + })?; Ok(RadrootsAppUiModalGuard { id, active: true }) } pub fn radroots_app_ui_modal_restore(id: u64) -> RadrootsAppUiModalResult<()> { - let mut state = modal_state() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let index = state.layers.iter().position(|layer| layer.id == id); - let Some(index) = index else { - return Ok(()); - }; - let removed = state.layers.remove(index); - modal_restore_hidden(&state.layers, removed.hidden)?; - Ok(()) + modal_state_with(|state| { + let index = state.layers.iter().position(|layer| layer.id == id); + let Some(index) = index else { + return Ok(()); + }; + let removed = state.layers.remove(index); + modal_restore_hidden(&state.layers, removed.hidden)?; + Ok(()) + }) } #[cfg(target_arch = "wasm32")] @@ -192,10 +211,7 @@ fn modal_is_hidden_by_layers(_layers: &[ModalLayer], _element: &RadrootsAppUiMod #[cfg(test)] fn modal_layer_count_for_test() -> usize { - let state = modal_state() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.layers.len() + modal_state_with(|state| state.layers.len()) } #[cfg(test)] diff --git a/crates/ui-primitives/src/dismissable.rs b/crates/ui-primitives/src/dismissable.rs @@ -44,21 +44,18 @@ pub fn RadrootsAppUiDismissableLayer( { use wasm_bindgen::closure::Closure; use wasm_bindgen::JsCast; + use send_wrapper::SendWrapper; let on_dismiss = on_dismiss.clone(); let on_pointer_down_outside = on_pointer_down_outside.clone(); let on_focus_outside = on_focus_outside.clone(); - let node_ref = node_ref.clone(); + let node_ref = node_ref; - on_mount(move || { - let document = match window().and_then(|window| window.document()) { + node_ref.on_load(move |root| { + let document = match web_sys::window().and_then(|window| window.document()) { Some(document) => document, None => return, }; - let root = match node_ref.get() { - Some(root) => root, - None => return, - }; if !disable_outside_pointer_events { let root_pointer = root.clone(); @@ -85,7 +82,16 @@ pub fn RadrootsAppUiDismissableLayer( "pointerdown", handler.as_ref().unchecked_ref(), ); - handler.forget(); + let cleanup_doc = SendWrapper::new(document.clone()); + let cleanup_handler = SendWrapper::new(handler); + on_cleanup(move || { + let document = cleanup_doc.take(); + let handler = cleanup_handler.take(); + let _ = document.remove_event_listener_with_callback( + "pointerdown", + handler.as_ref().unchecked_ref(), + ); + }); } let root_focus = root.clone(); @@ -112,7 +118,16 @@ pub fn RadrootsAppUiDismissableLayer( "focusin", focus_handler.as_ref().unchecked_ref(), ); - focus_handler.forget(); + let cleanup_doc = SendWrapper::new(document); + let cleanup_handler = SendWrapper::new(focus_handler); + on_cleanup(move || { + let document = cleanup_doc.take(); + let handler = cleanup_handler.take(); + let _ = document.remove_event_listener_with_callback( + "focusin", + handler.as_ref().unchecked_ref(), + ); + }); }); } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-primitives/src/focus.rs b/crates/ui-primitives/src/focus.rs @@ -2,6 +2,9 @@ use leptos::ev::KeyboardEvent; use leptos::html; use leptos::prelude::*; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; + const RADROOTS_APP_UI_FOCUSABLE_SELECTOR: &str = "a[href],button,textarea,input,select,[tabindex]:not([tabindex='-1'])"; @@ -44,22 +47,18 @@ pub fn RadrootsAppUiFocusScope( #[cfg(target_arch = "wasm32")] { use wasm_bindgen::closure::Closure; - use wasm_bindgen::JsCast; + use send_wrapper::SendWrapper; let on_mount_auto_focus = on_mount_auto_focus.clone(); let on_unmount_auto_focus = on_unmount_auto_focus.clone(); - let node_ref = node_ref.clone(); + let node_ref = node_ref; - on_mount(move || { - let document = match window().and_then(|window| window.document()) { + node_ref.on_load(move |root| { + let document = match web_sys::window().and_then(|window| window.document()) { Some(document) => document, None => return, }; - let root = match node_ref.get() { - Some(root) => root, - None => return, - }; - let previous_focus = document.active_element(); + let previous_focus = document.active_element().map(SendWrapper::new); if auto_focus { let _ = radroots_app_ui_focus_scope_focus_first(&root, &document); @@ -90,10 +89,13 @@ pub fn RadrootsAppUiFocusScope( if return_focus { let on_unmount_auto_focus = on_unmount_auto_focus.clone(); - let previous_focus = previous_focus.clone(); + let previous_focus = previous_focus; on_cleanup(move || { if let Some(element) = previous_focus { - let _ = element.dyn_ref::<web_sys::HtmlElement>().map(|el| el.focus()); + let element = element.take(); + let _ = element + .dyn_ref::<web_sys::HtmlElement>() + .map(|el| el.focus()); } if let Some(callback) = on_unmount_auto_focus.as_ref() { callback.run(()); @@ -135,16 +137,20 @@ fn radroots_app_ui_focus_scope_focus_first( .map_err(|_| ())?; if list.length() == 0 { if let Some(element) = root.dyn_ref::<web_sys::HtmlElement>() { - element.focus(); + let _ = element.focus(); } return Ok(()); } let first = list .item(0) .and_then(|node| node.dyn_into::<web_sys::HtmlElement>().ok()) - .or_else(|| document.active_element().and_then(|el| el.dyn_into().ok())); + .or_else(|| { + document + .active_element() + .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok()) + }); if let Some(element) = first { - element.focus(); + let _ = element.focus(); } Ok(()) } @@ -182,7 +188,7 @@ fn radroots_app_ui_focus_scope_cycle( .item(next_index as u32) .and_then(|node| node.dyn_into::<web_sys::HtmlElement>().ok()) { - next.focus(); + let _ = next.focus(); } Ok(()) } diff --git a/crates/ui-primitives/src/portal.rs b/crates/ui-primitives/src/portal.rs @@ -1,6 +1,9 @@ use leptos::prelude::*; #[cfg(target_arch = "wasm32")] +use leptos::portal::Portal; + +#[cfg(target_arch = "wasm32")] pub type RadrootsAppUiPortalMount = web_sys::Element; #[cfg(not(target_arch = "wasm32"))] @@ -8,12 +11,15 @@ pub type RadrootsAppUiPortalMount = (); #[component] pub fn RadrootsAppUiPortal( - #[prop(into, optional)] mount: Option<RadrootsAppUiPortalMount>, + #[prop(optional)] mount: Option<RadrootsAppUiPortalMount>, children: ChildrenFn, ) -> impl IntoView { #[cfg(target_arch = "wasm32")] { - view! { <Portal mount=mount>{children()}</Portal> } + match mount { + Some(mount) => view! { <Portal mount=mount>{children()}</Portal> }, + None => view! { <Portal>{children()}</Portal> }, + } } #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/ui-primitives/src/presence.rs b/crates/ui-primitives/src/presence.rs @@ -40,7 +40,7 @@ pub fn RadrootsAppUiPresence( #[prop(optional)] on_exit_complete: Option<Callback<()>>, children: ChildrenFn, ) -> impl IntoView { - let state = RwSignal::new(if present.get() { + let state = RwSignal::new(if present.get_untracked() { RadrootsAppUiPresenceState::Mounted } else { RadrootsAppUiPresenceState::Unmounted @@ -55,8 +55,13 @@ pub fn RadrootsAppUiPresence( let on_exit_complete = on_exit_complete.clone(); let end_handler = Arc::new(move || { - let next = radroots_app_ui_presence_state_next(state.get(), present.get(), true); - if next != state.get() { + let current_state = state.get_untracked(); + let next = radroots_app_ui_presence_state_next( + current_state, + present.get_untracked(), + true, + ); + if next != current_state { state.set(next); if next == RadrootsAppUiPresenceState::Unmounted { if let Some(callback) = on_exit_complete.as_ref() { diff --git a/crates/ui-primitives/src/scroll_lock.rs b/crates/ui-primitives/src/scroll_lock.rs @@ -84,8 +84,11 @@ fn scroll_lock_apply(state: &mut ScrollLockState) -> RadrootsAppUiScrollLockResu .body() .ok_or(RadrootsAppUiScrollLockError::BodyUnavailable)?; let style = body.style(); + let scroll_y = window + .scroll_y() + .map_err(|_| RadrootsAppUiScrollLockError::StyleUnavailable)?; let snapshot = ScrollLockSnapshot { - scroll_y: window.scroll_y(), + scroll_y, overflow: style_value(&style, "overflow")?, position: style_value(&style, "position")?, top: style_value(&style, "top")?,