From f7f1ea743826f548cdd96f3bc8d76c0768759a43 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 31 Aug 2026 21:11:18 +0700 Subject: [PATCH] fix web performance --- Cargo.lock | 3 ++ crates/chat/src/lib.rs | 52 +++++++++++---------- crates/chat_ui/src/lib.rs | 49 ++++++++++++-------- crates/person/Cargo.toml | 1 + crates/person/src/lib.rs | 92 ++++++++++++++++++++++++------------- crates/person/src/person.rs | 20 ++++++-- web/Cargo.toml | 2 + web/src/lib.rs | 12 ++++- web/www/vite.config.js | 6 +-- 9 files changed, 153 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36bbc772..691707c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1445,6 +1445,7 @@ dependencies = [ "gpui_platform", "gpui_web", "instant", + "js-sys", "log", "person", "settings", @@ -1455,6 +1456,7 @@ dependencies = [ "universal-time 0.3.1 (git+https://github.com/shadowylab/universal-time)", "wasm-bindgen", "wasm-bindgen-futures", + "web-sys", "workspace", ] @@ -4982,6 +4984,7 @@ dependencies = [ "anyhow", "common", "flume 0.11.1", + "futures", "gpui", "instant", "log", diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index ae5134b2..8f73e04e 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -290,33 +290,37 @@ impl ChatRegistry { self.signal_consumer = Some(cx.spawn(async move |this, cx| { while let Ok(message) = rx.recv_async().await { - match message { - Signal::Message(message) => { - this.update(cx, |this, cx| { - this.new_message(message, cx); - })?; + this.update(cx, |this, cx| { + // Drain the whole queue in a single update so a burst of + // events (e.g. history sync after login) collapses into + // one repaint instead of one per message (important on + // wasm, where everything runs on the main thread). + let mut batch = vec![message]; + while let Ok(extra) = rx.try_recv() { + batch.push(extra); } - Signal::InboxReady => { - this.update(cx, |this, cx| { - this.get_messages(cx); - })?; - } - Signal::Eose => { - this.update(cx, |this, _cx| { - this.tracking.store(false, Ordering::Release); - })?; - this.update(cx, |this, cx| { - this.get_rooms(cx); - })?; + for message in batch { + match message { + Signal::Message(message) => { + this.new_message(message, cx); + } + Signal::InboxReady => { + this.get_messages(cx); + } + Signal::Eose => { + this.tracking.store(false, Ordering::Release); + this.get_rooms(cx); + } + Signal::Error(failed) => { + let _ = trash.update(cx, |this, cx| { + this.insert(failed); + cx.notify(); + }); + } + }; } - Signal::Error(failed) => { - trash.update(cx, |this, cx| { - this.insert(failed); - cx.notify(); - })?; - } - }; + })?; } Ok(()) diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 16c81352..437fcb8e 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -242,29 +242,40 @@ impl ChatPanel { while let Ok(status) = rx.recv_async().await { { let mut map = reports.write().unwrap(); - let status_id = match &*status { - SendStatus::Ok { id, .. } => *id, - SendStatus::Failed { id, .. } => *id, - }; - // Find the matching report and update it (exit early on first match) - 'outer: for reports_list in map.values_mut() { - for report in reports_list.iter_mut() { - let Some(output) = report.output.as_mut() else { - continue; - }; - if *output.id() != status_id { - continue; - } - match &*status { - SendStatus::Ok { relay, .. } => { - output.success.insert(relay.clone(), EventSendStatus::Sent); + // Drain the whole queue in a single update so bursts of + // send statuses collapse into one repaint (important on + // wasm, where everything runs on the main thread). + let mut statuses = vec![status]; + while let Ok(extra) = rx.try_recv() { + statuses.push(extra); + } + + for status in statuses { + let status_id = match &*status { + SendStatus::Ok { id, .. } => *id, + SendStatus::Failed { id, .. } => *id, + }; + + // Find the matching report and update it (exit early on first match) + 'outer: for reports_list in map.values_mut() { + for report in reports_list.iter_mut() { + let Some(output) = report.output.as_mut() else { + continue; + }; + if *output.id() != status_id { + continue; } - SendStatus::Failed { relay, message, .. } => { - output.failed.insert(relay.clone(), message.clone()); + match &*status { + SendStatus::Ok { relay, .. } => { + output.success.insert(relay.clone(), EventSendStatus::Sent); + } + SendStatus::Failed { relay, message, .. } => { + output.failed.insert(relay.clone(), message.clone()); + } } + break 'outer; } - break 'outer; } } } diff --git a/crates/person/Cargo.toml b/crates/person/Cargo.toml index c1385580..3be9ed32 100644 --- a/crates/person/Cargo.toml +++ b/crates/person/Cargo.toml @@ -15,3 +15,4 @@ anyhow.workspace = true smallvec.workspace = true flume.workspace = true log.workspace = true +futures.workspace = true diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 28e41e4a..b0fe6bb0 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -3,7 +3,8 @@ use std::sync::RwLock; use anyhow::{Error, anyhow}; use common::EventExt; -use gpui::{App, AppContext, Context, Entity, Global, Task, Window}; +use futures::FutureExt; +use gpui::{App, AppContext, BackgroundExecutor, Context, Entity, Global, Task, Window}; use instant::Duration; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; @@ -72,24 +73,36 @@ impl PersonRegistry { })); let client3 = client.clone(); + let executor = cx.background_executor().clone(); tasks.push(cx.background_spawn(async move { - Self::handle_requests(&client3, &metadata_rx).await; + Self::handle_requests(&client3, &metadata_rx, &executor).await; })); tasks.push(cx.spawn(async move |this, cx| { while let Ok(event) = rx.recv_async().await { this.update(cx, |this, cx| { - match event { - Dispatch::Person(person) => { - this.insert(person, cx); - } - Dispatch::Announcement(event) => { - this.set_announcement(&event, cx); - } - Dispatch::Relays(event) => { - this.set_messaging_relays(&event, cx); - } - }; + // Drain the whole queue in a single update so a burst of + // events collapses into one repaint instead of one per + // event (important on wasm, where everything runs on the + // main thread). + let mut dispatch = vec![event]; + while let Ok(extra) = rx.try_recv() { + dispatch.push(extra); + } + + for event in dispatch { + match event { + Dispatch::Person(person) => { + this.insert(person, cx); + } + Dispatch::Announcement(event) => { + this.set_announcement(&event, cx); + } + Dispatch::Relays(event) => { + this.set_messaging_relays(&event, cx); + } + }; + } }) .ok(); } @@ -156,30 +169,43 @@ impl PersonRegistry { } /// Handle request for metadata - async fn handle_requests(client: &Client, rx: &flume::Receiver) { + /// + /// Requests are collected into batches and flushed when the batch is + /// full or the timeout expires. + /// + /// Note: `flume::Selector::wait_timeout` is intentionally not used here: + /// it relies on `std::time::Instant` and `thread::park_timeout`, which are + /// unavailable on `wasm32-unknown-unknown` (the former panics, the latter + /// is a no-op that would turn the wait into a busy loop on the main + /// thread). + async fn handle_requests( + client: &Client, + rx: &flume::Receiver, + executor: &BackgroundExecutor, + ) { let mut batch: HashSet = HashSet::new(); loop { - match flume::Selector::new() - .recv(rx, |result| result.ok()) - .wait_timeout(Duration::from_secs(TIMEOUT)) + // Wait for the next request, or the batch timeout. + futures::select! { + result = rx.recv_async() => match result { + Ok(public_key) => { + batch.insert(public_key); + // Keep collecting until the batch is full + if batch.len() < 20 { + continue; + } + } + Err(_) => return, + }, + _ = executor.timer(Duration::from_secs(TIMEOUT)).fuse() => {} + } + + // Flush the batch + if !batch.is_empty() + && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await { - Ok(Some(public_key)) => { - batch.insert(public_key); - // Process the batch if it's full - if batch.len() >= 20 - && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await - { - log::warn!("Failed to get metadata batch: {e}"); - } - } - _ => { - if !batch.is_empty() - && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await - { - log::warn!("Failed to get metadata batch: {e}"); - } - } + log::warn!("Failed to get metadata batch: {e}"); } } } diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index f597a197..0a872aa7 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -105,7 +105,21 @@ impl Person { /// Get profile avatar pub fn avatar(&self) -> SharedString { - self.metadata() + // On web, the browser blocks cross-origin image fetches unless the + // picture host sends CORS headers, so remote avatars can never load + // there. Each doomed fetch still triggers a full-window repaint when + // it fails, which is very costly on wasm's single main thread when a + // list renders many avatars at once. Fall back to the bundled avatar + // for remote pictures on web. + #[cfg(target_arch = "wasm32")] + if let Some(picture) = self.metadata.picture.as_ref() + && !picture.is_empty() + && url::Url::parse(picture).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) + { + return "brand/avatar.png".into(); + } + + self.metadata .picture .as_ref() .filter(|picture| !picture.is_empty()) @@ -115,13 +129,13 @@ impl Person { /// Get profile name pub fn name(&self) -> SharedString { - if let Some(display_name) = self.metadata().display_name.as_ref() + if let Some(display_name) = self.metadata.display_name.as_ref() && !display_name.is_empty() { return SharedString::from(display_name.trim()); } - if let Some(name) = self.metadata().name.as_ref() + if let Some(name) = self.metadata.name.as_ref() && !name.is_empty() { return SharedString::from(name.trim()); diff --git a/web/Cargo.toml b/web/Cargo.toml index 94237a8d..f63bed84 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -31,6 +31,8 @@ tracing-wasm = "0.2" console_log = "1.0" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Performance"] } universal-time = { git = "https://github.com/shadowylab/universal-time" } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/web/src/lib.rs b/web/src/lib.rs index 8ffd4955..38182977 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -11,13 +11,21 @@ struct CustomTimeProvider; impl WallClock for CustomTimeProvider { fn system_time(&self) -> SystemTime { - SystemTime::from_unix_duration(instant::Duration::from_secs(0)) + // Browser wall clock: milliseconds since the Unix epoch. + let millis = js_sys::Date::now(); + SystemTime::from_unix_duration(instant::Duration::from_millis(millis as u64)) } } impl MonotonicClock for CustomTimeProvider { fn instant(&self) -> Instant { - Instant::from_ticks(instant::Duration::from_secs(0)) + // `performance.now()` is monotonic; fall back to the wall clock if + // it's unavailable. + let millis = web_sys::window() + .and_then(|window| window.performance()) + .map(|performance| performance.now()) + .unwrap_or_else(js_sys::Date::now); + Instant::from_ticks(instant::Duration::from_millis(millis as u64)) } } diff --git a/web/www/vite.config.js b/web/www/vite.config.js index 3384959d..3a0b38f6 100644 --- a/web/www/vite.config.js +++ b/web/www/vite.config.js @@ -10,11 +10,11 @@ export default defineConfig({ viteStaticCopy({ targets: [ { - src: path.resolve(__dirname, "../../../assets/icons"), + src: path.resolve(__dirname, "../../assets/icons"), dest: "assets", }, { - src: path.resolve(__dirname, "../../../assets/brand"), + src: path.resolve(__dirname, "../../assets/brand"), dest: "assets", }, ], @@ -25,7 +25,7 @@ export default defineConfig({ server.middlewares.use( "/assets", (req, res, next) => { - const assetsPath = path.resolve(__dirname, "../../../assets"); + const assetsPath = path.resolve(__dirname, "../../assets"); const filePath = path.join( assetsPath, req.url.replace("/assets", ""),