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:
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")?,