Skip to content

Commit 1ab155d

Browse files
committed
Packages list: subscribe before snapshot so a clearing event can't leave a row stale-red
The autosync-paused row could stay wrongly red when a package resolved (publish or non-paused status) right as the list mounted. tauri_bridge::listen registers its backend listener asynchronously and Tauri does not replay events emitted before registration completes, so a clear landing between the snapshot capture and the listener going active was dropped, resolved_since_mount never recorded it, and the snapshot merge resurrected the stale pause. Add listen_with_ready, which returns the EventListener synchronously (cleanup unchanged) plus a future that resolves once the registration Promise settles (a clone of the same Promise the internal task awaits, so no new dependency). Register the status and published listeners with it and await both readiness futures before fetching the snapshot, closing the registration gap; keep the resolved_since_mount skip as the belt for the capture-to-merge window.
1 parent ff3997f commit 1ab155d

2 files changed

Lines changed: 90 additions & 34 deletions

File tree

quilt-sync/ui/src/pages/installed_packages_list.rs

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -90,43 +90,40 @@ pub fn InstalledPackagesList() -> impl IntoView {
9090
// *before* those events, so it must not resurrect a pause the user has
9191
// already resolved — the merge skips anything in this set.
9292
let resolved_since_mount: RwSignal<HashSet<String>> = RwSignal::new(HashSet::new());
93-
leptos::task::spawn_local(async move {
94-
if let Ok(snapshot) = commands::get_autosync_snapshot().await {
95-
let resolved = resolved_since_mount.get_untracked();
96-
paused_map.update(|map| {
97-
for entry in snapshot.paused {
98-
if resolved.contains(&entry.namespace) {
99-
continue;
100-
}
101-
if let Some(message) = entry.message {
102-
map.insert(entry.namespace, message);
103-
}
104-
}
105-
});
106-
}
107-
});
10893

109-
let listener = tauri_bridge::listen::<PackageStatusEvent>(PACKAGE_STATUS_EVENT, move |ev| {
110-
// Any non-paused status means the namespace is no longer
111-
// autosync-paused — drop it so the row stops rendering red.
112-
if ev.status != "paused" {
113-
let ns = ev.namespace.clone();
114-
paused_map.update(|map| {
115-
map.remove(&ns);
116-
});
117-
resolved_since_mount.update(|r| {
118-
r.insert(ns);
119-
});
120-
}
121-
status_event.set(Some(ev));
122-
});
94+
// Subscribe-then-fetch. `tauri_bridge::listen` registers the backend
95+
// listener asynchronously and Tauri does not replay events emitted
96+
// before the registration Promise resolves. So a clear (publish /
97+
// non-paused status) landing between the snapshot capture and the
98+
// listener becoming active would be dropped, `resolved_since_mount`
99+
// would never record it, and the merge below would resurrect the stale
100+
// pause. To close that window we register the two clear-relevant
101+
// listeners up front, then `.await` their readiness before fetching the
102+
// snapshot: any clear after the snapshot is captured is delivered to an
103+
// already-active listener, and any clear before capture is already
104+
// reflected in the snapshot itself.
105+
let (listener, status_ready) =
106+
tauri_bridge::listen_with_ready::<PackageStatusEvent>(PACKAGE_STATUS_EVENT, move |ev| {
107+
// Any non-paused status means the namespace is no longer
108+
// autosync-paused — drop it so the row stops rendering red.
109+
if ev.status != "paused" {
110+
let ns = ev.namespace.clone();
111+
paused_map.update(|map| {
112+
map.remove(&ns);
113+
});
114+
resolved_since_mount.update(|r| {
115+
r.insert(ns);
116+
});
117+
}
118+
status_event.set(Some(ev));
119+
});
123120
on_cleanup(move || drop(listener));
124121

125122
// Autosync publish events — emit a toast mirroring the manual
126123
// Commit & Push success notification, and clear any pause for the
127124
// published namespace so its row is no longer red.
128-
let publish_listener =
129-
tauri_bridge::listen::<PublishedEvent>(AUTOSYNC_PUBLISHED_EVENT, move |ev| {
125+
let (publish_listener, published_ready) =
126+
tauri_bridge::listen_with_ready::<PublishedEvent>(AUTOSYNC_PUBLISHED_EVENT, move |ev| {
130127
let ns = ev.namespace.clone();
131128
paused_map.update(|map| {
132129
map.remove(&ns);
@@ -141,6 +138,29 @@ pub fn InstalledPackagesList() -> impl IntoView {
141138
});
142139
on_cleanup(move || drop(publish_listener));
143140

141+
// Fetch the snapshot only after both clear-relevant listeners are active
142+
// on the backend (see subscribe-then-fetch note above). The
143+
// `resolved_since_mount` skip remains the belt for the residual
144+
// capture→merge window, where a clear can arrive after the snapshot is
145+
// captured but before this merge runs.
146+
leptos::task::spawn_local(async move {
147+
status_ready.await;
148+
published_ready.await;
149+
if let Ok(snapshot) = commands::get_autosync_snapshot().await {
150+
let resolved = resolved_since_mount.get_untracked();
151+
paused_map.update(|map| {
152+
for entry in snapshot.paused {
153+
if resolved.contains(&entry.namespace) {
154+
continue;
155+
}
156+
if let Some(message) = entry.message {
157+
map.insert(entry.namespace, message);
158+
}
159+
}
160+
});
161+
}
162+
});
163+
144164
// Autosync pause events — surface as a warning toast carrying the
145165
// reason. The detail page reads the same event to drive its
146166
// persistent banner, so the user sees both the immediate toast

quilt-sync/ui/src/tauri.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,32 @@ enum ListenerState {
6868
/// `Drop` and the JS side actually detaching.
6969
pub fn listen<T: DeserializeOwned + 'static>(
7070
event: &str,
71-
mut callback: impl FnMut(T) + 'static,
71+
callback: impl FnMut(T) + 'static,
7272
) -> EventListener {
73+
// Discarding the readiness future is safe: it is a plain async block
74+
// (nothing is spawned) so dropping it unpolled runs no work and leaks
75+
// nothing — callers that don't care when registration completes keep
76+
// the old synchronous ergonomics.
77+
listen_with_ready(event, callback).0
78+
}
79+
80+
/// Like [`listen`], but also exposes when the backend listener is
81+
/// *active*. `tauri_listen_raw` registers asynchronously: it returns a
82+
/// Promise and events emitted before that Promise resolves are dropped
83+
/// (Tauri does not replay). Callers that must observe every event from a
84+
/// point in time onward can `.await` the returned future before doing the
85+
/// thing that triggers those events (e.g. fetching a snapshot), so no
86+
/// event falls into the registration gap.
87+
///
88+
/// The `EventListener` is returned synchronously — `on_cleanup` can drop
89+
/// it exactly as with [`listen`]. The future resolves once the same
90+
/// registration Promise has settled (resolved *or* rejected: a failed
91+
/// registration is logged by the internal task, and hanging the caller
92+
/// forever would be worse than proceeding).
93+
pub fn listen_with_ready<T: DeserializeOwned + 'static>(
94+
event: &str,
95+
mut callback: impl FnMut(T) + 'static,
96+
) -> (EventListener, impl std::future::Future<Output = ()>) {
7397
let event_name = event.to_string();
7498
let event_name_for_closure = event_name.clone();
7599
let closure: Closure<dyn FnMut(JsValue)> = Closure::new(move |raw: JsValue| {
@@ -83,6 +107,12 @@ pub fn listen<T: DeserializeOwned + 'static>(
83107
let promise = tauri_listen_raw(&event_name, closure.as_ref().unchecked_ref());
84108
closure.forget();
85109

110+
// A JS Promise can be awaited by multiple consumers; a clone shares the
111+
// same underlying settlement. The internal task below awaits `promise`
112+
// to capture the `unlisten` function, while the readiness future awaits
113+
// this clone — both fire when the backend listener becomes active.
114+
let ready_promise = promise.clone();
115+
86116
// `SendWrapper` lets the !Send JS handle satisfy `on_cleanup`'s
87117
// `Send + Sync` bound; WASM is single-threaded so the wrapper
88118
// never panics in practice.
@@ -116,9 +146,15 @@ pub fn listen<T: DeserializeOwned + 'static>(
116146
}
117147
});
118148

119-
EventListener {
149+
let listener = EventListener {
120150
state: SendWrapper::new(state),
121-
}
151+
};
152+
let ready = async move {
153+
// Settlement (resolve or reject) is the readiness signal; on reject
154+
// the internal task already logged the failure.
155+
let _ = JsFuture::from(ready_promise).await;
156+
};
157+
(listener, ready)
122158
}
123159

124160
pub struct EventListener {

0 commit comments

Comments
 (0)