diff --git a/packages/router-core/Cargo.toml b/packages/router-core/Cargo.toml index 255a0182a..1034a856c 100644 --- a/packages/router-core/Cargo.toml +++ b/packages/router-core/Cargo.toml @@ -13,14 +13,15 @@ futures-util = "0.3.25" gloo = { version = "0.8.0", optional = true } log = "0.4.17" regex = { version = "1.6.0", optional = true } -serde = { version = "1.0.147", optional = true } +serde = { version = "1.0.147", optional = true, features = ["derive"] } +serde-wasm-bindgen = { version = "0.4.5", optional = true } serde_urlencoded = { version = "0.7.1", optional = true } url = "2.3.1" urlencoding = "2.1.2" wasm-bindgen = { version = "0.2.83", optional = true } -web-sys = { version = "0.3.60", optional = true } +web-sys = { version = "0.3.60", optional = true, features = ["ScrollRestoration"]} [features] regex = ["dep:regex"] serde = ["dep:serde", "serde_urlencoded"] -web = ["gloo", "wasm-bindgen", "web-sys"] +web = ["gloo", "dep:serde", "serde-wasm-bindgen", "wasm-bindgen", "web-sys"] diff --git a/packages/router-core/src/history/mod.rs b/packages/router-core/src/history/mod.rs index 9a18a1337..327513ec0 100644 --- a/packages/router-core/src/history/mod.rs +++ b/packages/router-core/src/history/mod.rs @@ -8,11 +8,6 @@ use std::sync::Arc; -#[cfg(feature = "web")] -mod web_hash; -#[cfg(feature = "web")] -pub use web_hash::*; - mod memory; pub use memory::*; @@ -21,6 +16,14 @@ mod web; #[cfg(feature = "web")] pub use web::*; +#[cfg(feature = "web")] +mod web_hash; +#[cfg(feature = "web")] +pub use web_hash::*; + +#[cfg(feature = "web")] +pub(crate) mod web_scroll; + /// An integration with some kind of navigation history. /// /// Depending on your use case, your implementation may deviate from the described procedure. This diff --git a/packages/router-core/src/history/web.rs b/packages/router-core/src/history/web.rs index 13f293928..3154a6be7 100644 --- a/packages/router-core/src/history/web.rs +++ b/packages/router-core/src/history/web.rs @@ -1,11 +1,13 @@ -use gloo::{ - history::{BrowserHistory, History, HistoryListener}, - utils::window, -}; -use log::error; -use web_sys::Window; +use std::sync::{Arc, Mutex}; -use super::HistoryProvider; +use gloo::{events::EventListener, render::AnimationFrame}; +use log::error; +use web_sys::{window, History, ScrollRestoration, Window}; + +use super::{ + web_scroll::{top_left, update_history, update_scroll}, + HistoryProvider, +}; /// A [`HistoryProvider`] that integrates with a browser via the [History API]. /// @@ -22,38 +24,60 @@ use super::HistoryProvider; /// /// [History API]: https://developer.mozilla.org/en-US/docs/Web/API/History_API pub struct WebHistory { - history: BrowserHistory, - listener_navigation: Option, + do_scroll_restoration: bool, + history: History, + listener_navigation: Option, + #[allow(dead_code)] + listener_scroll: Option, + listener_animation_frame: Arc>>, prefix: Option, window: Window, } impl WebHistory { - /// Create a new [`WebHistory`] with a prefix. - #[must_use] - pub fn with_prefix(prefix: impl Into) -> Self { - Self { - prefix: Some(prefix.into()), - ..Default::default() - } - } -} + /// Create a new [`WebHistory`]. + /// + /// If `do_scroll_restoration` is [`true`], [`WebHistory`] will take control of the history + /// state. It'll also set the browsers scroll restoration to `manual`. + pub fn new(prefix: Option, do_scroll_restoration: bool) -> Self { + let window = window().expect("access to `window`"); + let history = window.history().expect("`window` has access to `history`"); + + let listener_scroll = match do_scroll_restoration { + true => { + history + .set_scroll_restoration(ScrollRestoration::Manual) + .expect("`history` can set scroll restoration"); + let w = window.clone(); + let h = history.clone(); + let document = w.document().expect("`window` has access to `document`"); + + Some(EventListener::new(&document, "scroll", move |_| { + update_history(&w, &h); + })) + } + false => None, + }; -impl Default for WebHistory { - fn default() -> Self { Self { - history: BrowserHistory::new(), + do_scroll_restoration, + history, listener_navigation: None, - prefix: None, - window: window(), + listener_scroll, + listener_animation_frame: Default::default(), + prefix: prefix, + window, } } } impl HistoryProvider for WebHistory { fn current_path(&self) -> String { - let location = self.history.location(); - let path = location.path(); + let path = self + .window + .location() + .pathname() + .unwrap_or_else(|_| String::from("/")); match &self.prefix { None => path.to_string(), @@ -66,14 +90,17 @@ impl HistoryProvider for WebHistory { } fn current_query(&self) -> Option { - let location = self.history.location(); - let query = location.query_str(); - - if query.is_empty() { - None - } else { - Some(query.to_string()) - } + self.window + .location() + .search() + .ok() + .map(|mut q| { + if q.starts_with('?') { + q.remove(0); + } + q + }) + .and_then(|q| q.is_empty().then_some(q)) } fn current_prefix(&self) -> Option { @@ -81,25 +108,61 @@ impl HistoryProvider for WebHistory { } fn go_back(&mut self) { - self.history.back(); + if let Err(e) = self.history.back() { + error!("failed to go back: {e:?}") + } } fn go_forward(&mut self) { - self.history.forward(); + if let Err(e) = self.history.forward() { + error!("failed to go forward: {e:?}") + } } fn push(&mut self, path: String) { - self.history.push(match &self.prefix { + let path = match &self.prefix { None => path, Some(prefix) => format!("{prefix}{path}"), - }); + }; + + let state = match self.do_scroll_restoration { + true => top_left(), + false => self.history.state().unwrap_or_default(), + }; + + let nav = self.history.push_state_with_url(&state, "", Some(&path)); + + match nav { + Ok(_) => { + if self.do_scroll_restoration { + self.window.scroll_to_with_x_and_y(0.0, 0.0) + } + } + Err(e) => error!("failed to push state: {e:?}"), + } } fn replace(&mut self, path: String) { - self.history.replace(match &self.prefix { + let path = match &self.prefix { None => path, Some(prefix) => format!("{prefix}{path}"), - }); + }; + + let state = match self.do_scroll_restoration { + true => top_left(), + false => self.history.state().unwrap_or_default(), + }; + + let nav = self.history.replace_state_with_url(&state, "", Some(&path)); + + match nav { + Ok(_) => { + if self.do_scroll_restoration { + self.window.scroll_to_with_x_and_y(0.0, 0.0) + } + } + Err(e) => error!("failed to replace state: {e:?}"), + } } fn external(&mut self, url: String) -> bool { @@ -113,6 +176,17 @@ impl HistoryProvider for WebHistory { } fn updater(&mut self, callback: std::sync::Arc) { - self.listener_navigation = Some(self.history.listen(move || (*callback)())); + let w = self.window.clone(); + let h = self.history.clone(); + let s = self.listener_animation_frame.clone(); + let d = self.do_scroll_restoration; + + self.listener_navigation = Some(EventListener::new(&self.window, "popstate", move |_| { + (*callback)(); + if d { + let mut s = s.lock().expect("unpoisoned scroll mutex"); + *s = Some(update_scroll(&w, &h)); + } + })); } } diff --git a/packages/router-core/src/history/web_hash.rs b/packages/router-core/src/history/web_hash.rs index b80479ec2..edfb885b4 100644 --- a/packages/router-core/src/history/web_hash.rs +++ b/packages/router-core/src/history/web_hash.rs @@ -1,10 +1,14 @@ -use gloo::{events::EventListener, utils::window}; +use std::sync::{Arc, Mutex}; + +use gloo::{events::EventListener, render::AnimationFrame, utils::window}; use log::error; use url::Url; -use wasm_bindgen::JsValue; -use web_sys::{History, Window}; +use web_sys::{History, ScrollRestoration, Window}; -use super::HistoryProvider; +use super::{ + web_scroll::{top_left, update_history, update_scroll}, + HistoryProvider, +}; const INITIAL_URL: &str = "dioxus-router-core://initial_url.invalid/"; @@ -18,16 +22,50 @@ const INITIAL_URL: &str = "dioxus-router-core://initial_url.invalid/"; /// /// [History API]: https://developer.mozilla.org/en-US/docs/Web/API/History_API pub struct WebHashHistory { + do_scroll_restoration: bool, history: History, + listener_navigation: Option, + #[allow(dead_code)] + listener_scroll: Option, + listener_animation_frame: Arc>>, window: Window, } -impl Default for WebHashHistory { - fn default() -> Self { +impl WebHashHistory { + /// Create a new [`WebHashHistory`]. + /// + /// If `do_scroll_restoration` is [`true`], [`WebHashHistory`] will take control of the history + /// state. It'll also set the browsers scroll restoration to `manual`. + pub fn new(do_scroll_restoration: bool) -> Self { let window = window(); + let history = window.history().expect("`window` has access to `history`"); + + history + .set_scroll_restoration(ScrollRestoration::Manual) + .expect("`history` can set scroll restoration"); + + let listener_scroll = match do_scroll_restoration { + true => { + history + .set_scroll_restoration(ScrollRestoration::Manual) + .expect("`history` can set scroll restoration"); + let w = window.clone(); + let h = history.clone(); + let document = w.document().expect("`window` has access to `document`"); + + Some(EventListener::new(&document, "scroll", move |_| { + update_history(&w, &h); + })) + } + false => None, + }; Self { - history: window.history().expect("window has history"), + do_scroll_restoration, + history, + listener_navigation: None, + listener_scroll, + listener_animation_frame: Default::default(), window, } } @@ -115,11 +153,20 @@ impl HistoryProvider for WebHashHistory { None => return, }; - if let Err(e) = self - .history - .push_state_with_url(&JsValue::NULL, "", Some(&hash)) - { - error!("failed to push state: {e:?}"); + let state = match self.do_scroll_restoration { + true => top_left(), + false => self.history.state().unwrap_or_default(), + }; + + let nav = self.history.push_state_with_url(&state, "", Some(&hash)); + + match nav { + Ok(_) => { + if self.do_scroll_restoration { + self.window.scroll_to_with_x_and_y(0.0, 0.0) + } + } + Err(e) => error!("failed to push state: {e:?}"), } } @@ -129,11 +176,20 @@ impl HistoryProvider for WebHashHistory { None => return, }; - if let Err(e) = self - .history - .replace_state_with_url(&JsValue::NULL, "", Some(&hash)) - { - error!("failed to replace state: {e:?}"); + let state = match self.do_scroll_restoration { + true => top_left(), + false => self.history.state().unwrap_or_default(), + }; + + let nav = self.history.replace_state_with_url(&state, "", Some(&hash)); + + match nav { + Ok(_) => { + if self.do_scroll_restoration { + self.window.scroll_to_with_x_and_y(0.0, 0.0) + } + } + Err(e) => error!("failed to replace state: {e:?}"), } } @@ -148,6 +204,17 @@ impl HistoryProvider for WebHashHistory { } fn updater(&mut self, callback: std::sync::Arc) { - let listener = EventListener::new(&self.window, "popstate", move |_| (*callback)()); + let w = self.window.clone(); + let h = self.history.clone(); + let s = self.listener_animation_frame.clone(); + let d = self.do_scroll_restoration; + + self.listener_navigation = Some(EventListener::new(&self.window, "popstate", move |_| { + (*callback)(); + if d { + let mut s = s.lock().expect("unpoisoned scroll mutex"); + *s = Some(update_scroll(&w, &h)); + } + })); } } diff --git a/packages/router-core/src/history/web_scroll.rs b/packages/router-core/src/history/web_scroll.rs new file mode 100644 index 000000000..d38b74196 --- /dev/null +++ b/packages/router-core/src/history/web_scroll.rs @@ -0,0 +1,38 @@ +use gloo::render::{request_animation_frame, AnimationFrame}; +use log::error; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::JsValue; +use web_sys::{History, Window}; + +#[derive(Debug, Default, Deserialize, Serialize)] +pub(crate) struct ScrollPosition { + x: f64, + y: f64, +} + +pub(crate) fn top_left() -> JsValue { + serde_wasm_bindgen::to_value(&ScrollPosition::default()).unwrap() +} + +pub(crate) fn update_history(window: &Window, history: &History) { + let position = serde_wasm_bindgen::to_value(&ScrollPosition { + x: window.scroll_x().unwrap_or_default(), + y: window.scroll_y().unwrap_or_default(), + }) + .unwrap(); + + if let Err(e) = history.replace_state(&position, "") { + error!("failed to update scroll position: {e:?}"); + } +} + +pub(crate) fn update_scroll(window: &Window, history: &History) -> AnimationFrame +{ + let ScrollPosition { x, y } = history + .state() + .map(|state| serde_wasm_bindgen::from_value(state).unwrap_or_default()) + .unwrap_or_default(); + + let w = window.clone(); + request_animation_frame(move |_| w.scroll_to_with_x_and_y(x, y)) +} diff --git a/packages/router/examples/simple.rs b/packages/router/examples/simple.rs index 72ffb6b2f..d7e572271 100644 --- a/packages/router/examples/simple.rs +++ b/packages/router/examples/simple.rs @@ -1,7 +1,7 @@ #![allow(non_snake_case)] use dioxus::prelude::*; -use dioxus_router::prelude::*; +use dioxus_router::{history::MemoryHistory, prelude::*}; fn main() { #[cfg(not(feature = "web"))] @@ -13,8 +13,16 @@ fn main() { fn App(cx: Scope) -> Element { use_router( &cx, - &|| RouterConfiguration { - ..Default::default() + &|| { + #[cfg(not(feature = "web"))] + let history = MemoryHistory::default(); + #[cfg(feature = "web")] + let history = dioxus_router::history::WebHistory::new(None, true); + + RouterConfiguration { + history: Box::new(history), + ..Default::default() + } }, &|| { Segment::content(comp(Home)) diff --git a/packages/router/src/hooks/use_router.rs b/packages/router/src/hooks/use_router.rs index 5dc58993a..667b7b63d 100644 --- a/packages/router/src/hooks/use_router.rs +++ b/packages/router/src/hooks/use_router.rs @@ -188,16 +188,11 @@ pub struct RouterConfiguration { impl Default for RouterConfiguration { fn default() -> Self { - #[cfg(not(feature = "web"))] - let history = Box::::default(); - #[cfg(feature = "web")] - let history = Box::::default(); - Self { failure_external_navigation: comp(FailureExternalNavigation), failure_named_navigation: comp(FailureNamedNavigation), failure_redirection_limit: comp(FailureRedirectionLimit), - history: history, + history: Box::::default(), on_update: None, synchronous: false, }