From c2fe76c28fa9111936741e4b2b5e3e7e079fc89e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 16:59:11 +0000 Subject: [PATCH] fix(web): route component handler errors through warn_and_toast_with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #443 closed #350 by routing client-mutation errors in handlers.rs through warn_and_toast / warn_and_toast_with so the user sees a toast instead of the action silently vanishing. The pattern was not propagated to component-internal handlers — 11 sites across roles.rs, settings.rs, sync_queue_view.rs, and channel_sidebar.rs still discarded the anyhow::Result with `let _ = h.foo(...).await`. Migrate all 11 sites: - roles.rs: create role, set permission, assign role, delete role - settings.rs: set server display name, mute server - sync_queue_view.rs: retry queue - channel_sidebar.rs: create channel, create voice channel, delete channel, mute channel Each site now captures `let toasts = use_context::()` on the outer reactive frame (before `wasm_bindgen_futures::spawn_local`, which strips the reactive owner), moves it into the async block, and dispatches `crate::handlers::warn_and_toast_with("", &e, toasts.as_ref())` on Err. `rg -n 'let _ = h\.[a-z_]+\(.*\)\.await' crates/web/src/components/` goes from 11 hits to 0. Refs #476 --- crates/web/src/components/channel_sidebar.rs | 43 +++++++++++++++++--- crates/web/src/components/roles.rs | 37 ++++++++++++++--- crates/web/src/components/settings.rs | 22 ++++++++-- crates/web/src/components/sync_queue_view.rs | 8 +++- 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/crates/web/src/components/channel_sidebar.rs b/crates/web/src/components/channel_sidebar.rs index c580bc35..83a9af36 100644 --- a/crates/web/src/components/channel_sidebar.rs +++ b/crates/web/src/components/channel_sidebar.rs @@ -15,7 +15,7 @@ use leptos::prelude::*; use crate::app::WebClientHandle; use crate::components::{ ConfirmDialog, ContextMenu, StatusDot, StatusDotBorder, StatusDotSize, TempChannelCreateForm, - VoiceControls, TEMP_DEFAULT_DAYS, + ToastStack, VoiceControls, TEMP_DEFAULT_DAYS, }; use crate::icons; @@ -185,13 +185,29 @@ pub fn ChannelSidebar( } else if let Some(kind) = kind { let h = handle_create.clone(); let name_owned = name.clone(); + // Capture toast stack on the outer reactive frame — + // `spawn_local` strips the owner so `use_context` + // inside the async block would return None. + let toasts = use_context::(); wasm_bindgen_futures::spawn_local(async move { match kind { willow_state::ChannelKind::Voice => { - let _ = h.create_voice_channel(&name_owned).await; + if let Err(e) = h.create_voice_channel(&name_owned).await { + crate::handlers::warn_and_toast_with( + "create voice channel", + &e, + toasts.as_ref(), + ); + } } _ => { - let _ = h.create_channel(&name_owned).await; + if let Err(e) = h.create_channel(&name_owned).await { + crate::handlers::warn_and_toast_with( + "create channel", + &e, + toasts.as_ref(), + ); + } } } }); @@ -658,8 +674,15 @@ pub fn ChannelSidebar( on_confirm=Callback::new(move |_| { if let Some(name) = pending_del_channel.get_untracked() { let h = handle_del_confirm.clone(); + let toasts = use_context::(); wasm_bindgen_futures::spawn_local(async move { - let _ = h.delete_channel(&name).await; + if let Err(e) = h.delete_channel(&name).await { + crate::handlers::warn_and_toast_with( + "delete channel", + &e, + toasts.as_ref(), + ); + } }); } set_pending_del_channel.set(None); @@ -974,8 +997,18 @@ fn render_channel_row( let channel = name_for_mute.clone(); let h = handle.clone(); let target = !is_muted.get_untracked(); + // Capture toast stack on the outer reactive frame — + // `spawn_local` strips the owner so `use_context` + // inside the async block would return None. + let toasts = use_context::(); wasm_bindgen_futures::spawn_local(async move { - let _ = h.mutate_channel_mute(&channel, target).await; + if let Err(e) = h.mutate_channel_mute(&channel, target).await { + crate::handlers::warn_and_toast_with( + "mute channel", + &e, + toasts.as_ref(), + ); + } }); } > diff --git a/crates/web/src/components/roles.rs b/crates/web/src/components/roles.rs index 59f0dfb0..8db97e4a 100644 --- a/crates/web/src/components/roles.rs +++ b/crates/web/src/components/roles.rs @@ -1,7 +1,7 @@ use leptos::prelude::*; use crate::app::WebClientHandle; -use crate::components::ConfirmDialog; +use crate::components::{ConfirmDialog, ToastStack}; use crate::state::AppState; /// List of all permission names that can be toggled on a role. @@ -52,8 +52,14 @@ pub fn RoleManager( let name = name.trim().to_string(); if !name.is_empty() { let h = handle_create.clone(); + // Capture toast stack on the outer reactive frame — + // `spawn_local` strips the owner so `use_context` inside + // the async block would return None. + let toasts = use_context::(); wasm_bindgen_futures::spawn_local(async move { - let _ = h.create_role(&name).await; + if let Err(e) = h.create_role(&name).await { + crate::handlers::warn_and_toast_with("create role", &e, toasts.as_ref()); + } }); } set_new_name.set(String::new()); @@ -197,6 +203,7 @@ pub fn RoleManager( let h = hp_t.clone(); let rid = rid_t.clone(); let perm = perm_toggle.clone(); + let toasts = use_context::(); wasm_bindgen_futures::spawn_local(async move { // Names come from PERMISSION_NAMES which is // kept in sync with willow_state::Permission; @@ -204,7 +211,13 @@ pub fn RoleManager( if let Some(parsed) = willow_state::Permission::from_name(&perm) { - let _ = h.set_permission(&rid, parsed, granted).await; + if let Err(e) = h.set_permission(&rid, parsed, granted).await { + crate::handlers::warn_and_toast_with( + "set permission", + &e, + toasts.as_ref(), + ); + } } }); } @@ -242,8 +255,15 @@ pub fn RoleManager( if let Ok(eid) = pid.trim().parse::() { let h = ha.clone(); let r = rid.clone(); + let toasts = use_context::(); wasm_bindgen_futures::spawn_local(async move { - let _ = h.assign_role(eid, &r).await; + if let Err(e) = h.assign_role(eid, &r).await { + crate::handlers::warn_and_toast_with( + "assign role", + &e, + toasts.as_ref(), + ); + } }); } set_assign_peer.set(String::new()); @@ -287,8 +307,15 @@ pub fn RoleManager( on_confirm=Callback::new(move |_| { if let Some((rid, _)) = pending_del_role.get_untracked() { let h = handle_del_confirm.clone(); + let toasts = use_context::(); wasm_bindgen_futures::spawn_local(async move { - let _ = h.delete_role(&rid).await; + if let Err(e) = h.delete_role(&rid).await { + crate::handlers::warn_and_toast_with( + "delete role", + &e, + toasts.as_ref(), + ); + } }); } set_pending_del_role.set(None); diff --git a/crates/web/src/components/settings.rs b/crates/web/src/components/settings.rs index 849eead0..37d5f7b4 100644 --- a/crates/web/src/components/settings.rs +++ b/crates/web/src/components/settings.rs @@ -2,7 +2,7 @@ use leptos::prelude::*; use willow_client::presence::{PresenceOverride, PresenceState}; use crate::app::WebClientHandle; -use crate::components::{RoleManager, StatusDot, StatusDotBorder, StatusDotSize}; +use crate::components::{RoleManager, StatusDot, StatusDotBorder, StatusDotSize, ToastStack}; use crate::icons; use crate::state::{AppState, SettingsTab}; use crate::util::copy_to_clipboard; @@ -54,8 +54,18 @@ pub fn SettingsPanel( if !name.trim().is_empty() { let h = handle_save.clone(); let name = name.trim().to_string(); + // Capture toast stack on the outer reactive frame — + // `spawn_local` strips the owner so `use_context` inside + // the async block would return None. + let toasts = use_context::(); wasm_bindgen_futures::spawn_local(async move { - let _ = h.set_server_display_name(&name).await; + if let Err(e) = h.set_server_display_name(&name).await { + crate::handlers::warn_and_toast_with( + "set server display name", + &e, + toasts.as_ref(), + ); + } }); } set_status_msg.set("Saved.".to_string()); @@ -435,8 +445,14 @@ fn NotificationsTabPlaceholder() -> impl IntoView { let target = !local_muted.get_untracked(); set_local_muted.set(target); let h = handle.clone(); + // Capture toast stack on the outer reactive frame — + // `spawn_local` strips the owner so `use_context` inside + // the async block would return None. + let toasts = use_context::(); wasm_bindgen_futures::spawn_local(async move { - let _ = h.mutate_grove_mute(target).await; + if let Err(e) = h.mutate_grove_mute(target).await { + crate::handlers::warn_and_toast_with("mute server", &e, toasts.as_ref()); + } }); } }; diff --git a/crates/web/src/components/sync_queue_view.rs b/crates/web/src/components/sync_queue_view.rs index 70090c5f..d39fbb6f 100644 --- a/crates/web/src/components/sync_queue_view.rs +++ b/crates/web/src/components/sync_queue_view.rs @@ -76,8 +76,14 @@ pub fn SyncQueueView() -> impl IntoView { busy.set(false); return; }; + // Capture toast stack on the outer reactive frame — + // `spawn_local` strips the owner so `use_context` inside the + // async block would return None. + let toasts = use_context::(); wasm_bindgen_futures::spawn_local(async move { - let _ = h.retry_queue().await; + if let Err(e) = h.retry_queue().await { + crate::handlers::warn_and_toast_with("retry queue", &e, toasts.as_ref()); + } busy.set(false); }); };