Skip to content

Commit b975e8c

Browse files
committed
refactor(services): make AsyncState the single async-UI code path
The AsyncState/from_value/IsEmpty primitive shipped behind #[allow(dead_code)] with zero non-test consumers: the notification popover re-implemented the four state arms inline instead of calling from_value, so the unit tests validated a mapping the render path never ran. Worse, the two diverged — from_value derived Empty from the raw feed while the popover derived it from the capability-filtered list — so the tests gave false confidence about the live behavior. Root cause was StudioError not being Clone, which blocked owning a Resource read out of its guard and forced the inline copy. NodeDbError is Clone, so: - Derive Clone on StudioError (cheap, lossless) so a resource value can be cloned out of its guard and mapped through from_value. - Drop the dead_code allows on AsyncState/IsEmpty/from_value — now genuinely used. - Add is_loading/is_empty/error_message/is_retriable/loaded accessors so views drive AsyncView from one AsyncState without re-deriving flags by hand. - Rewrite the notification popover to map the Ok payload to the capability- filtered list BEFORE from_value, then render via the accessors. The render path and the unit tests now share one code path, and Empty correctly reflects what the active connection can see. fmt + clippy -D warnings + nextest all green (31 tests, was 27).
1 parent f25b514 commit b975e8c

4 files changed

Lines changed: 127 additions & 85 deletions

File tree

nodedb-studio/src/components/async_view.rs

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,24 @@
55
//! states to this component. Keeps every wired view from re-implementing the
66
//! spinner / empty / error+retry markup.
77
//!
8-
//! Calling convention (wired up by plan 01-04):
8+
//! Calling convention: build one `AsyncState<T>` via `from_value` and drive both
9+
//! this component and the loaded markup from it — no inline match arms:
910
//! ```ignore
10-
//! match AsyncState::from_value(resource.read().clone()) {
11-
//! AsyncState::Loaded(data) => rsx! { /* caller's own markup for `data` */ },
12-
//! other => rsx! {
13-
//! AsyncView {
14-
//! loading: matches!(other, AsyncState::Loading),
15-
//! empty: matches!(other, AsyncState::Empty),
16-
//! error: match &other { AsyncState::Error(e) => Some(e.to_string()), _ => None },
17-
//! retriable: matches!(&other, AsyncState::Error(e) if e.is_retriable()),
18-
//! on_retry: move |_| resource.restart(),
19-
//! }
11+
//! let state = AsyncState::from_value(resource.read().clone());
12+
//! rsx! {
13+
//! AsyncView {
14+
//! loading: state.is_loading(),
15+
//! empty: state.is_empty(),
16+
//! error: state.error_message(),
17+
//! retriable: state.is_retriable(),
18+
//! on_retry: move |_| resource.restart(),
2019
//! }
20+
//! if let Some(data) = state.loaded() { /* caller's own markup for `data` */ }
2121
//! }
2222
//! ```
2323
//! Discrete props (rather than the generic `AsyncState<T>`) are used because
24-
//! Dioxus props must be `Clone + PartialEq`; `StudioError` is neither, and `T`
25-
//! is unconstrained — so the caller decodes the state and passes flags.
24+
//! Dioxus props must be `Clone + PartialEq`; `StudioError`/`T` need not be — so
25+
//! the caller decodes the state via the accessors and passes plain flags.
2626
2727
use dioxus::prelude::*;
2828

nodedb-studio/src/components/popovers/notification_popover.rs

Lines changed: 32 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,17 @@
11
//! Notification popover: capability-filtered, grouped list with a bell badge,
22
//! mark-all-read, and click-to-navigate.
33
//!
4-
//! SEAM-04 render-path proof: the rendered list is fetched through the async
5-
//! `ConnectionService` seam via `use_resource` (reading `Rc<dyn ConnectionService>`
6-
//! from context), and the four UI states are surfaced exactly as the
7-
//! `AsyncState`/`AsyncView` primitive (01-03) prescribes — Loading / Empty /
8-
//! Error render via the shared `AsyncView`, Loaded renders the existing grouped
9-
//! list. The Error state offers a Retry gated on `StudioError::is_retriable()`,
10-
//! wired to `Resource::restart()`.
4+
//! The rendered list is fetched through the async `ConnectionService` seam via
5+
//! `use_resource`, then mapped to `AsyncState` and rendered through the shared
6+
//! `AsyncView` — the canonical async-UI pattern. Capability filtering is applied
7+
//! to the `Ok` payload *before* `AsyncState::from_value`, so `Empty` reflects
8+
//! what THIS connection can see (not the raw feed). The Error state offers a
9+
//! Retry gated on `StudioError::is_retriable()`, wired to `Resource::restart()`.
1110
//!
12-
//! Reconciliation with 01-02's app.rs seeding: plan 01-02 seeds a global
13-
//! `Signal<Vec<Notification>>` that `topbar.rs` reads for the unread bell badge.
14-
//! That signal and the topbar badge are LEFT UNCHANGED here — the badge keeps
15-
//! deriving from the app.rs-seeded signal, and mark-all-read / per-item clicks
16-
//! still mutate it so the badge stays in sync. The popover ADDITIONALLY
17-
//! self-fetches via `use_resource` as the live proof of the async pattern. The
18-
//! minor redundancy (badge from signal, list from resource) is intentional and
19-
//! acceptable for Phase 1; later phases converge the two onto one source.
11+
//! The topbar bell badge derives from a separate global `Signal<Vec<Notification>>`
12+
//! seeded in `app.rs`; mark-all-read / per-item clicks mutate that signal so the
13+
//! badge stays in sync. Converging the badge and this list onto one source is
14+
//! deferred to a later phase.
2015
2116
use std::rc::Rc;
2217

@@ -25,6 +20,7 @@ use dioxus::prelude::*;
2520
use crate::components::async_view::AsyncView;
2621
use crate::models::notification::{Notification, NotificationTarget};
2722
use crate::routes::Route;
23+
use crate::services::async_state::AsyncState;
2824
use crate::services::connection_service::ConnectionService;
2925
use crate::state::connection::{ActiveConnection, Capabilities};
3026
use crate::state::notifications::visible;
@@ -62,47 +58,30 @@ pub fn NotificationPopover() -> Element {
6258
None => return rsx! {},
6359
};
6460

65-
// SEAM-04 PROOF: fetch the feed through the async seam. Clone the Rc BEFORE
66-
// the async block; never hold a signal/Resource guard across `.await`.
61+
// Fetch the feed through the async seam. Clone the Rc BEFORE the async block;
62+
// never hold a signal/Resource guard across `.await`.
6763
let mut feed = use_resource(move || {
6864
let service = service.clone();
6965
async move { service.notifications().await } // Result<Vec<Notification>, StudioError>
7066
});
7167

72-
// Map the resource read -> the four AsyncState states (mirrors
73-
// AsyncState::from_value: None->Loading, Some(Err)->Error,
74-
// Some(Ok(empty))->Empty, Some(Ok(data))->Loaded). StudioError is not Clone,
75-
// so derive (message, retriable) by reference while the guard is held, clone
76-
// ONLY the Ok Vec (Notification is Clone), then DROP the guard before render.
77-
let mut loading = false;
78-
let mut empty = false;
79-
let mut error_msg: Option<String> = None;
80-
let mut retriable = false;
81-
let mut loaded: Option<Vec<Notification>> = None;
82-
{
83-
let read = feed.read();
84-
match &*read {
85-
None => loading = true,
86-
Some(Err(e)) => {
87-
error_msg = Some(e.to_string()); // Display
88-
retriable = e.is_retriable(); // gates Retry
89-
}
90-
Some(Ok(list)) => {
91-
// Capability-filter here so Empty reflects what THIS connection sees.
92-
let vis: Vec<Notification> = visible(&list[..], &caps).cloned().collect();
93-
if vis.is_empty() {
94-
empty = true;
95-
} else {
96-
loaded = Some(vis);
97-
}
98-
}
99-
}
100-
} // guard dropped here — nothing held across the render below
68+
// Clone the resource value out of its guard (StudioError is Clone), mapping
69+
// the Ok payload to the capability-filtered list so `Empty` reflects what THIS
70+
// connection sees — then derive every UI state from one `AsyncState`. This is
71+
// the same `from_value` mapping the unit tests exercise; the render path and
72+
// the tests share one code path.
73+
let state: AsyncState<Vec<Notification>> = {
74+
let mapped = feed
75+
.read()
76+
.clone()
77+
.map(|res| res.map(|list| visible(&list[..], &caps).cloned().collect::<Vec<_>>()));
78+
AsyncState::from_value(mapped)
79+
}; // guard dropped here — nothing held across the render below
10180

10281
// Build the grouped list ONLY for the Loaded case (preserves first-seen order).
10382
let mut groups: Vec<(String, Vec<Notification>)> = Vec::new();
10483
let mut unread = 0usize;
105-
if let Some(ref items) = loaded {
84+
if let Some(items) = state.loaded() {
10685
unread = items.iter().filter(|n| n.unread).count();
10786
for n in items {
10887
match groups.iter_mut().find(|(g, _)| g == &n.group) {
@@ -131,17 +110,17 @@ pub fn NotificationPopover() -> Element {
131110
}
132111
}
133112
div { class: "notif-list",
134-
// Loading / Empty / Error -> shared AsyncView (the proof).
113+
// Loading / Empty / Error -> shared AsyncView, driven by AsyncState.
135114
AsyncView {
136-
loading,
137-
empty,
138-
error: error_msg.clone(),
139-
retriable,
115+
loading: state.is_loading(),
116+
empty: state.is_empty(),
117+
error: state.error_message(),
118+
retriable: state.is_retriable(),
140119
empty_message: "No notifications for this connection".to_string(),
141120
on_retry: move |_| feed.restart(),
142121
}
143122
// Loaded -> the existing grouped list markup.
144-
if loaded.is_some() {
123+
if state.loaded().is_some() {
145124
for (group_name, items) in groups {
146125
div { class: "notif-group-label", "{group_name}" }
147126
for n in items {
@@ -184,7 +163,6 @@ pub fn NotificationPopover() -> Element {
184163
#[cfg(test)]
185164
mod tests {
186165
use super::*;
187-
use crate::services::async_state::AsyncState;
188166
use crate::services::error::StudioError;
189167

190168
#[test]

nodedb-studio/src/services/async_state.rs

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@ use crate::services::error::StudioError;
88

99
/// Anything that can report emptiness, so `from_value` can distinguish a
1010
/// loaded-but-empty result (-> Empty) from a loaded-with-data result.
11-
// Scoped allow: see the note on `AsyncState` below. `IsEmpty` exists only to
12-
// bound `from_value`, which is exercised by tests + mirrored (not called) in the
13-
// popover render path, so the non-test build sees it as unused.
14-
#[allow(dead_code)]
1511
pub trait IsEmpty {
1612
fn is_empty(&self) -> bool;
1713
}
@@ -22,15 +18,10 @@ impl<T> IsEmpty for Vec<T> {
2218
}
2319
}
2420

25-
// Scoped allow: `AsyncState`/`from_value` are the canonical, unit-tested mapping
26-
// from a `use_resource` read to the four UI states. The 01-04 popover render path
27-
// uses approach (A) from the plan — it MIRRORS these four match arms inline
28-
// rather than CALLING `from_value`, because `StudioError` is not `Clone` and
29-
// cannot be owned out of a `Resource` read guard for the Error arm. `from_value`
30-
// is therefore referenced only by tests, so the non-test build flags it (and the
31-
// enum it returns) as dead. The allow is kept deliberately; phases 3-6 that can
32-
// own their resource values WILL call `from_value` directly, removing the need.
33-
#[allow(dead_code)]
21+
/// The canonical, unit-tested mapping from a `use_resource` read to the four UI
22+
/// states. Wired views call `from_value` directly (cloning the resource value
23+
/// out of its guard — `StudioError` is `Clone`) and then drive `AsyncView` via
24+
/// the accessors below, so no view re-implements the match arms inline.
3425
pub enum AsyncState<T> {
3526
Loading,
3627
Empty,
@@ -44,9 +35,9 @@ impl<T: IsEmpty> AsyncState<T> {
4435
/// Some(Err(e)) -> Error(e)
4536
/// Some(Ok(empty)) -> Empty
4637
/// Some(Ok(data)) -> Loaded(data)
47-
// Scoped allow: see the note on `AsyncState` above (test-only call site this
48-
// phase; the popover mirrors these arms inline because StudioError is not Clone).
49-
#[allow(dead_code)]
38+
///
39+
/// To make `Empty` reflect a post-filtered view (e.g. capability filtering),
40+
/// map the `Ok` payload to the filtered collection *before* calling this.
5041
pub fn from_value(v: Option<Result<T, StudioError>>) -> Self {
5142
match v {
5243
None => AsyncState::Loading,
@@ -57,6 +48,42 @@ impl<T: IsEmpty> AsyncState<T> {
5748
}
5849
}
5950

51+
impl<T> AsyncState<T> {
52+
/// True while the first fetch is pending. Feeds `AsyncView`'s `loading` prop.
53+
pub fn is_loading(&self) -> bool {
54+
matches!(self, AsyncState::Loading)
55+
}
56+
57+
/// True when the fetch resolved to no rows. Feeds `AsyncView`'s `empty` prop.
58+
pub fn is_empty(&self) -> bool {
59+
matches!(self, AsyncState::Empty)
60+
}
61+
62+
/// `Some(message)` when the fetch failed; `None` otherwise. Feeds
63+
/// `AsyncView`'s `error` prop (Display string, since `StudioError` is not a
64+
/// Dioxus-compatible prop type).
65+
pub fn error_message(&self) -> Option<String> {
66+
match self {
67+
AsyncState::Error(e) => Some(e.to_string()),
68+
_ => None,
69+
}
70+
}
71+
72+
/// Whether the failed fetch is retriable. Feeds `AsyncView`'s `retriable`
73+
/// prop, which gates the Retry button.
74+
pub fn is_retriable(&self) -> bool {
75+
matches!(self, AsyncState::Error(e) if e.is_retriable())
76+
}
77+
78+
/// The loaded payload, if any — the caller renders its own markup for it.
79+
pub fn loaded(&self) -> Option<&T> {
80+
match self {
81+
AsyncState::Loaded(t) => Some(t),
82+
_ => None,
83+
}
84+
}
85+
}
86+
6087
#[cfg(test)]
6188
mod tests {
6289
use super::*;
@@ -84,4 +111,39 @@ mod tests {
84111
let s = AsyncState::<Vec<u8>>::from_value(Some(Err(StudioError::NotConnected)));
85112
assert!(matches!(s, AsyncState::Error(_)));
86113
}
114+
115+
#[test]
116+
fn accessors_for_loading() {
117+
let s = AsyncState::<Vec<u8>>::from_value(None);
118+
assert!(s.is_loading());
119+
assert!(!s.is_empty());
120+
assert_eq!(s.error_message(), None);
121+
assert!(!s.is_retriable());
122+
assert!(s.loaded().is_none());
123+
}
124+
125+
#[test]
126+
fn accessors_for_empty() {
127+
let s = AsyncState::from_value(Some(Ok(Vec::<u8>::new())));
128+
assert!(s.is_empty());
129+
assert!(!s.is_loading());
130+
assert!(s.loaded().is_none());
131+
}
132+
133+
#[test]
134+
fn accessors_for_loaded() {
135+
let s = AsyncState::from_value(Some(Ok(vec![1u8, 2, 3])));
136+
assert_eq!(s.loaded(), Some(&vec![1u8, 2, 3]));
137+
assert!(!s.is_loading());
138+
assert!(!s.is_empty());
139+
}
140+
141+
#[test]
142+
fn accessors_for_error() {
143+
// `NotConnected` is non-retriable; its Display message is surfaced.
144+
let s = AsyncState::<Vec<u8>>::from_value(Some(Err(StudioError::NotConnected)));
145+
assert!(s.error_message().is_some());
146+
assert!(!s.is_retriable());
147+
assert!(!s.is_loading());
148+
}
87149
}

nodedb-studio/src/services/error.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ use nodedb_types::error::ErrorDetails;
99
use thiserror::Error;
1010

1111
// The seam's Result error: returned by every `ConnectionService` method and
12-
// consumed by the views in later plans (01-03..04).
13-
#[derive(Debug, Error)]
12+
// consumed by the views. `Clone` so a `Resource` read can be cloned out of its
13+
// guard and mapped through `AsyncState::from_value` (the canonical render path);
14+
// `NodeDbError` is itself `Clone`, so this is a cheap, lossless derive.
15+
#[derive(Debug, Clone, Error)]
1416
pub enum StudioError {
1517
#[error("connection error: {0}")]
1618
Connection(#[source] NodeDbError),

0 commit comments

Comments
 (0)