dioxus_web/
history.rs

1use wasm_bindgen::{prelude::Closure, JsCast, JsValue};
2use web_sys::{window, Event, History, ScrollRestoration, Window};
3
4/// A [`dioxus_history::History`] provider that integrates with a browser via the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API).
5///
6/// # Prefix
7/// This [`dioxus_history::History`] supports a prefix, which can be used for web apps that aren't located
8/// at the root of their domain.
9///
10/// Application developers are responsible for ensuring that right after the prefix comes a `/`. If
11/// that is not the case, this [`dioxus_history::History`] will replace the first character after the prefix
12/// with one.
13///
14/// Application developers are responsible for not rendering the router if the prefix is not present
15/// in the URL. Otherwise, if a router navigation is triggered, the prefix will be added.
16pub struct WebHistory {
17    do_scroll_restoration: bool,
18    history: History,
19    prefix: Option<String>,
20    window: Window,
21}
22
23impl Default for WebHistory {
24    fn default() -> Self {
25        Self::new(None, true)
26    }
27}
28
29impl WebHistory {
30    /// Create a new [`WebHistory`].
31    ///
32    /// If `do_scroll_restoration` is [`true`], [`WebHistory`] will take control of the history
33    /// state. It'll also set the browsers scroll restoration to `manual`.
34    pub fn new(prefix: Option<String>, do_scroll_restoration: bool) -> Self {
35        let myself = Self::new_inner(prefix, do_scroll_restoration);
36
37        let current_route = dioxus_history::History::current_route(&myself);
38        let current_route_str = current_route.to_string();
39        let prefix_str = myself.prefix.as_deref().unwrap_or("");
40        let current_url = format!("{prefix_str}{current_route_str}");
41        let state = myself.create_state();
42        let _ = replace_state_with_url(&myself.history, &state, Some(&current_url));
43
44        myself
45    }
46
47    fn new_inner(prefix: Option<String>, do_scroll_restoration: bool) -> Self {
48        let window = window().expect("access to `window`");
49        let history = window.history().expect("`window` has access to `history`");
50
51        if do_scroll_restoration {
52            history
53                .set_scroll_restoration(ScrollRestoration::Manual)
54                .expect("`history` can set scroll restoration");
55        }
56
57        let prefix = prefix
58            // If there isn't a base path, try to grab one from the CLI
59            .or_else(dioxus_cli_config::web_base_path)
60            // Normalize the prefix to start and end with no slashes
61            .as_ref()
62            .map(|prefix| prefix.trim_matches('/'))
63            // If the prefix is empty, don't add it
64            .filter(|prefix| !prefix.is_empty())
65            // Otherwise, start with a slash
66            .map(|prefix| format!("/{prefix}"));
67
68        Self {
69            do_scroll_restoration,
70            history,
71            prefix,
72            window,
73        }
74    }
75
76    fn scroll_pos(&self) -> ScrollPosition {
77        if self.do_scroll_restoration {
78            ScrollPosition::of_window(&self.window)
79        } else {
80            Default::default()
81        }
82    }
83
84    fn create_state(&self) -> [f64; 2] {
85        let scroll = self.scroll_pos();
86        [scroll.x, scroll.y]
87    }
88
89    fn handle_nav(&self) {
90        if self.do_scroll_restoration {
91            self.window.scroll_to_with_x_and_y(0.0, 0.0)
92        }
93    }
94
95    fn route_from_location(&self) -> String {
96        let location = self.window.location();
97        let path = location.pathname().unwrap_or_else(|_| "/".into())
98            + &location.search().unwrap_or("".into())
99            + &location.hash().unwrap_or("".into());
100        let mut path = match self.prefix {
101            None => &path,
102            Some(ref prefix) => path.strip_prefix(prefix).unwrap_or(prefix),
103        };
104        // If the path is empty, parse the root route instead
105        if path.is_empty() {
106            path = "/"
107        }
108        path.to_string()
109    }
110
111    fn full_path(&self, state: &String) -> String {
112        match &self.prefix {
113            None => state.to_string(),
114            Some(prefix) => format!("{prefix}{state}"),
115        }
116    }
117}
118
119impl dioxus_history::History for WebHistory {
120    fn current_route(&self) -> String {
121        self.route_from_location()
122    }
123
124    fn current_prefix(&self) -> Option<String> {
125        self.prefix.clone()
126    }
127
128    fn go_back(&self) {
129        let _ = self.history.back();
130    }
131
132    fn go_forward(&self) {
133        let _ = self.history.forward();
134    }
135
136    fn push(&self, state: String) {
137        if state == self.current_route() {
138            // don't push the same state twice
139            return;
140        }
141
142        let w = window().expect("access to `window`");
143        let h = w.history().expect("`window` has access to `history`");
144
145        // update the scroll position before pushing the new state
146        update_scroll(&w, &h);
147
148        if push_state_and_url(&self.history, &self.create_state(), self.full_path(&state)).is_ok() {
149            self.handle_nav();
150        }
151    }
152
153    fn replace(&self, state: String) {
154        if replace_state_with_url(
155            &self.history,
156            &self.create_state(),
157            Some(&self.full_path(&state)),
158        )
159        .is_ok()
160        {
161            self.handle_nav();
162        }
163    }
164
165    fn external(&self, url: String) -> bool {
166        self.window.location().set_href(&url).is_ok()
167    }
168
169    fn updater(&self, callback: std::sync::Arc<dyn Fn() + Send + Sync>) {
170        let w = self.window.clone();
171        let h = self.history.clone();
172        let d = self.do_scroll_restoration;
173
174        let function = Closure::wrap(Box::new(move |_| {
175            (*callback)();
176            if d {
177                if let Some([x, y]) = get_current(&h) {
178                    ScrollPosition { x, y }.scroll_to(w.clone())
179                }
180            }
181        }) as Box<dyn FnMut(Event)>);
182        self.window
183            .add_event_listener_with_callback(
184                "popstate",
185                &function.into_js_value().unchecked_into(),
186            )
187            .unwrap();
188    }
189}
190
191/// A [`dioxus_history::History`] provider that integrates with a browser via the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API)
192/// but uses the url fragment for the route. This allows serving as a single html file or on a single url path.
193pub struct HashHistory {
194    do_scroll_restoration: bool,
195    history: History,
196    pathname: String,
197    window: Window,
198}
199
200impl Default for HashHistory {
201    fn default() -> Self {
202        Self::new(true)
203    }
204}
205
206impl HashHistory {
207    /// Create a new [`HashHistory`].
208    ///
209    /// If `do_scroll_restoration` is [`true`], [`HashHistory`] will take control of the history
210    /// state. It'll also set the browsers scroll restoration to `manual`.
211    pub fn new(do_scroll_restoration: bool) -> Self {
212        let myself = Self::new_inner(do_scroll_restoration);
213
214        let current_route = dioxus_history::History::current_route(&myself);
215        let current_route_str = current_route.to_string();
216        let pathname_str = &myself.pathname;
217        let current_url = format!("{pathname_str}#{current_route_str}");
218        let state = myself.create_state();
219        let _ = replace_state_with_url(&myself.history, &state, Some(&current_url));
220
221        myself
222    }
223
224    fn new_inner(do_scroll_restoration: bool) -> Self {
225        let window = window().expect("access to `window`");
226        let history = window.history().expect("`window` has access to `history`");
227        let pathname = window.location().pathname().unwrap();
228
229        if do_scroll_restoration {
230            history
231                .set_scroll_restoration(ScrollRestoration::Manual)
232                .expect("`history` can set scroll restoration");
233        }
234
235        Self {
236            do_scroll_restoration,
237            history,
238            pathname,
239            window,
240        }
241    }
242
243    fn scroll_pos(&self) -> ScrollPosition {
244        if self.do_scroll_restoration {
245            ScrollPosition::of_window(&self.window)
246        } else {
247            Default::default()
248        }
249    }
250
251    fn create_state(&self) -> [f64; 2] {
252        let scroll = self.scroll_pos();
253        [scroll.x, scroll.y]
254    }
255
256    fn full_path(&self, state: &String) -> String {
257        format!("{}#{state}", self.pathname)
258    }
259
260    fn handle_nav(&self) {
261        if self.do_scroll_restoration {
262            self.window.scroll_to_with_x_and_y(0.0, 0.0)
263        }
264    }
265}
266
267impl dioxus_history::History for HashHistory {
268    fn current_route(&self) -> String {
269        let location = self.window.location();
270
271        let hash = location.hash().unwrap();
272        if hash.is_empty() {
273            // If the path is empty, parse the root route instead
274            "/".to_owned()
275        } else {
276            hash.trim_start_matches("#").to_owned()
277        }
278    }
279
280    fn current_prefix(&self) -> Option<String> {
281        Some(format!("{}#", self.pathname))
282    }
283
284    fn go_back(&self) {
285        let _ = self.history.back();
286    }
287
288    fn go_forward(&self) {
289        let _ = self.history.forward();
290    }
291
292    fn push(&self, state: String) {
293        if state == self.current_route() {
294            // don't push the same state twice
295            return;
296        }
297
298        let w = window().expect("access to `window`");
299        let h = w.history().expect("`window` has access to `history`");
300
301        // update the scroll position before pushing the new state
302        update_scroll(&w, &h);
303
304        if push_state_and_url(&self.history, &self.create_state(), self.full_path(&state)).is_ok() {
305            self.handle_nav();
306        }
307    }
308
309    fn replace(&self, state: String) {
310        if replace_state_with_url(
311            &self.history,
312            &self.create_state(),
313            Some(&self.full_path(&state)),
314        )
315        .is_ok()
316        {
317            self.handle_nav();
318        }
319    }
320
321    fn external(&self, url: String) -> bool {
322        self.window.location().set_href(&url).is_ok()
323    }
324
325    fn updater(&self, callback: std::sync::Arc<dyn Fn() + Send + Sync>) {
326        let w = self.window.clone();
327        let h = self.history.clone();
328        let d = self.do_scroll_restoration;
329
330        let function = Closure::wrap(Box::new(move |_| {
331            (*callback)();
332            if d {
333                if let Some([x, y]) = get_current(&h) {
334                    ScrollPosition { x, y }.scroll_to(w.clone())
335                }
336            }
337        }) as Box<dyn FnMut(Event)>);
338        self.window
339            .add_event_listener_with_callback(
340                "popstate",
341                &function.into_js_value().unchecked_into(),
342            )
343            .unwrap();
344    }
345}
346
347#[derive(Clone, Copy, Debug, Default)]
348pub(crate) struct ScrollPosition {
349    pub x: f64,
350    pub y: f64,
351}
352
353impl ScrollPosition {
354    pub(crate) fn of_window(window: &Window) -> Self {
355        Self {
356            x: window.scroll_x().unwrap_or_default(),
357            y: window.scroll_y().unwrap_or_default(),
358        }
359    }
360
361    pub(crate) fn scroll_to(&self, window: Window) {
362        let Self { x, y } = *self;
363        let f = Closure::wrap(
364            Box::new(move || window.scroll_to_with_x_and_y(x, y)) as Box<dyn FnMut()>
365        );
366        web_sys::window()
367            .expect("should be run in a context with a `Window` object (dioxus cannot be run from a web worker)")
368            .request_animation_frame(&f.into_js_value().unchecked_into())
369            .expect("should register `requestAnimationFrame` OK");
370    }
371}
372
373pub(crate) fn replace_state_with_url(
374    history: &History,
375    value: &[f64; 2],
376    url: Option<&str>,
377) -> Result<(), JsValue> {
378    let position = js_sys::Array::new();
379    position.push(&JsValue::from(value[0]));
380    position.push(&JsValue::from(value[1]));
381    history.replace_state_with_url(&position, "", url)
382}
383
384pub(crate) fn push_state_and_url(
385    history: &History,
386    value: &[f64; 2],
387    url: String,
388) -> Result<(), JsValue> {
389    let position = js_sys::Array::new();
390    position.push(&JsValue::from(value[0]));
391    position.push(&JsValue::from(value[1]));
392    history.push_state_with_url(&position, "", Some(&url))
393}
394
395pub(crate) fn get_current(history: &History) -> Option<[f64; 2]> {
396    use wasm_bindgen::JsCast;
397    history.state().ok().and_then(|state| {
398        let state = state.dyn_into::<js_sys::Array>().ok()?;
399        let x = state.get(0).as_f64()?;
400        let y = state.get(1).as_f64()?;
401        Some([x, y])
402    })
403}
404
405fn update_scroll(window: &Window, history: &History) {
406    let scroll = ScrollPosition::of_window(window);
407    let _ = replace_state_with_url(history, &[scroll.x, scroll.y], None);
408}