diff --git a/packages/oneclient_app/src/hooks/debounce.rs b/packages/oneclient_app/src/hooks/debounce.rs new file mode 100644 index 00000000..98c8d432 --- /dev/null +++ b/packages/oneclient_app/src/hooks/debounce.rs @@ -0,0 +1,31 @@ +use std::time::Duration; + +use freya::prelude::*; + +/// Returns a debounced mirror of `value`. The returned state only updates to a +/// new `value` once `delay` has elapsed without any further change. Rapid +/// changes (e.g. per-keystroke) collapse into a single trailing update. +pub fn use_debounced(value: T, delay: Duration) -> State +where + T: Clone + PartialEq + 'static, +{ + let debounced = use_state(|| value.clone()); + // Bumped on every change; the async task commits only if its generation is + // still the latest when the delay expires, dropping superseded changes. + let mut generation = use_state(|| 0u64); + + use_side_effect_with_deps(&value, move |value| { + let value = value.clone(); + let this_gen = *generation.peek() + 1; + generation.set(this_gen); + let mut debounced = debounced; + spawn(async move { + tokio::time::sleep(delay).await; + if *generation.peek() == this_gen { + debounced.set(value); + } + }); + }); + + debounced +} diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 80b1adfe..b323677e 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -1,8 +1,10 @@ mod active_cluster; +mod debounce; mod dispatch; mod queries; mod view_state; +pub use debounce::use_debounced; pub use view_state::{PersistedView, use_view_state}; pub use active_cluster::{ diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 77df9ea6..a1e7660d 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use freya::animation::{ AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies, }; @@ -12,7 +14,7 @@ use crate::components::{ }; use crate::hooks::{ BROWSE_PAGE_SIZE, BrowserUiState, category_list, content_type_for_slug, search_items, - search_pending, search_total, use_browser_compat, use_browser_state_store, + search_pending, search_total, use_browser_compat, use_browser_state_store, use_debounced, use_package_categories, use_package_search, use_view_state, }; use crate::theme::colors; @@ -37,6 +39,7 @@ const BANNER_H: f32 = 100.; const LIST_ROW_H: f32 = 78.; const GRID_SPACING: f32 = 12.; const LIST_SPACING: f32 = 8.; +const SEARCH_DEBOUNCE_MS: u64 = 250; fn type_title(package_type: &str) -> &'static str { match package_type { @@ -87,6 +90,12 @@ impl Component for Browser { }); } + // Debounce the search query so typing doesn't fire a request per keystroke. + let debounced_query = use_debounced( + query.read().clone(), + Duration::from_millis(SEARCH_DEBOUNCE_MS), + ); + let cluster = load_cluster(cluster_id); let provider_id = *provider.read(); let compat = *compatible_only.read(); @@ -107,7 +116,7 @@ impl Component for Browser { let mut page_state = page; let signature = format!( "{provider_id:?}|{}|{compat}|{}", - query.read().trim().to_lowercase(), + debounced_query.read().trim().to_lowercase(), cats.join(",") ); let mut last_signature = use_state(|| signature.clone()); @@ -121,7 +130,7 @@ impl Component for Browser { let search = use_package_search( provider_id, content_type, - query.read().clone(), + debounced_query.read().clone(), game_versions, loaders, cats.clone(),