fix web performance

This commit is contained in:
2026-08-31 21:11:18 +07:00
parent 070c6a7e87
commit f7f1ea7438
9 changed files with 153 additions and 84 deletions
Generated
+3
View File
@@ -1445,6 +1445,7 @@ dependencies = [
"gpui_platform", "gpui_platform",
"gpui_web", "gpui_web",
"instant", "instant",
"js-sys",
"log", "log",
"person", "person",
"settings", "settings",
@@ -1455,6 +1456,7 @@ dependencies = [
"universal-time 0.3.1 (git+https://github.com/shadowylab/universal-time)", "universal-time 0.3.1 (git+https://github.com/shadowylab/universal-time)",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys",
"workspace", "workspace",
] ]
@@ -4982,6 +4984,7 @@ dependencies = [
"anyhow", "anyhow",
"common", "common",
"flume 0.11.1", "flume 0.11.1",
"futures",
"gpui", "gpui",
"instant", "instant",
"log", "log",
+28 -24
View File
@@ -290,33 +290,37 @@ impl ChatRegistry {
self.signal_consumer = Some(cx.spawn(async move |this, cx| { self.signal_consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(message) = rx.recv_async().await { while let Ok(message) = rx.recv_async().await {
match message { this.update(cx, |this, cx| {
Signal::Message(message) => { // Drain the whole queue in a single update so a burst of
this.update(cx, |this, cx| { // events (e.g. history sync after login) collapses into
this.new_message(message, cx); // 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| { for message in batch {
this.get_rooms(cx); 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(()) Ok(())
+30 -19
View File
@@ -242,29 +242,40 @@ impl ChatPanel {
while let Ok(status) = rx.recv_async().await { while let Ok(status) = rx.recv_async().await {
{ {
let mut map = reports.write().unwrap(); 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) // Drain the whole queue in a single update so bursts of
'outer: for reports_list in map.values_mut() { // send statuses collapse into one repaint (important on
for report in reports_list.iter_mut() { // wasm, where everything runs on the main thread).
let Some(output) = report.output.as_mut() else { let mut statuses = vec![status];
continue; while let Ok(extra) = rx.try_recv() {
}; statuses.push(extra);
if *output.id() != status_id { }
continue;
} for status in statuses {
match &*status { let status_id = match &*status {
SendStatus::Ok { relay, .. } => { SendStatus::Ok { id, .. } => *id,
output.success.insert(relay.clone(), EventSendStatus::Sent); 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, .. } => { match &*status {
output.failed.insert(relay.clone(), message.clone()); 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;
} }
} }
} }
+1
View File
@@ -15,3 +15,4 @@ anyhow.workspace = true
smallvec.workspace = true smallvec.workspace = true
flume.workspace = true flume.workspace = true
log.workspace = true log.workspace = true
futures.workspace = true
+59 -33
View File
@@ -3,7 +3,8 @@ use std::sync::RwLock;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use common::EventExt; 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 instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
@@ -72,24 +73,36 @@ impl PersonRegistry {
})); }));
let client3 = client.clone(); let client3 = client.clone();
let executor = cx.background_executor().clone();
tasks.push(cx.background_spawn(async move { 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| { tasks.push(cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await { while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
match event { // Drain the whole queue in a single update so a burst of
Dispatch::Person(person) => { // events collapses into one repaint instead of one per
this.insert(person, cx); // event (important on wasm, where everything runs on the
} // main thread).
Dispatch::Announcement(event) => { let mut dispatch = vec![event];
this.set_announcement(&event, cx); while let Ok(extra) = rx.try_recv() {
} dispatch.push(extra);
Dispatch::Relays(event) => { }
this.set_messaging_relays(&event, cx);
} 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(); .ok();
} }
@@ -156,30 +169,43 @@ impl PersonRegistry {
} }
/// Handle request for metadata /// Handle request for metadata
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) { ///
/// 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<PublicKey>,
executor: &BackgroundExecutor,
) {
let mut batch: HashSet<PublicKey> = HashSet::new(); let mut batch: HashSet<PublicKey> = HashSet::new();
loop { loop {
match flume::Selector::new() // Wait for the next request, or the batch timeout.
.recv(rx, |result| result.ok()) futures::select! {
.wait_timeout(Duration::from_secs(TIMEOUT)) 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)) => { log::warn!("Failed to get metadata batch: {e}");
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}");
}
}
} }
} }
} }
+17 -3
View File
@@ -105,7 +105,21 @@ impl Person {
/// Get profile avatar /// Get profile avatar
pub fn avatar(&self) -> SharedString { 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 .picture
.as_ref() .as_ref()
.filter(|picture| !picture.is_empty()) .filter(|picture| !picture.is_empty())
@@ -115,13 +129,13 @@ impl Person {
/// Get profile name /// Get profile name
pub fn name(&self) -> SharedString { 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() && !display_name.is_empty()
{ {
return SharedString::from(display_name.trim()); 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() && !name.is_empty()
{ {
return SharedString::from(name.trim()); return SharedString::from(name.trim());
+2
View File
@@ -31,6 +31,8 @@ tracing-wasm = "0.2"
console_log = "1.0" console_log = "1.0"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" 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" } universal-time = { git = "https://github.com/shadowylab/universal-time" }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
+10 -2
View File
@@ -11,13 +11,21 @@ struct CustomTimeProvider;
impl WallClock for CustomTimeProvider { impl WallClock for CustomTimeProvider {
fn system_time(&self) -> SystemTime { 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 { impl MonotonicClock for CustomTimeProvider {
fn instant(&self) -> Instant { 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))
} }
} }
+3 -3
View File
@@ -10,11 +10,11 @@ export default defineConfig({
viteStaticCopy({ viteStaticCopy({
targets: [ targets: [
{ {
src: path.resolve(__dirname, "../../../assets/icons"), src: path.resolve(__dirname, "../../assets/icons"),
dest: "assets", dest: "assets",
}, },
{ {
src: path.resolve(__dirname, "../../../assets/brand"), src: path.resolve(__dirname, "../../assets/brand"),
dest: "assets", dest: "assets",
}, },
], ],
@@ -25,7 +25,7 @@ export default defineConfig({
server.middlewares.use( server.middlewares.use(
"/assets", "/assets",
(req, res, next) => { (req, res, next) => {
const assetsPath = path.resolve(__dirname, "../../../assets"); const assetsPath = path.resolve(__dirname, "../../assets");
const filePath = path.join( const filePath = path.join(
assetsPath, assetsPath,
req.url.replace("/assets", ""), req.url.replace("/assets", ""),