Skip to main content

dioxus_web/
history.rs

1use wasm_bindgen::{JsCast, JsValue, prelude::Closure};
2use web_sys::{Event, History, ScrollRestoration, Window, 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 && let Some([x, y]) = get_current(&h) {
177                ScrollPosition { x, y }.scroll_to(w.clone())
178            }
179        }) as Box<dyn FnMut(Event)>);
180        self.window
181            .add_event_listener_with_callback(
182                "popstate",
183                &function.into_js_value().unchecked_into(),
184            )
185            .unwrap();
186    }
187}
188
189/// 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)
190/// but uses the url fragment for the route. This allows serving as a single html file or on a single url path.
191pub struct HashHistory {
192    do_scroll_restoration: bool,
193    history: History,
194    pathname: String,
195    window: Window,
196}
197
198impl Default for HashHistory {
199    fn default() -> Self {
200        Self::new(true)
201    }
202}
203
204impl HashHistory {
205    /// Create a new [`HashHistory`].
206    ///
207    /// If `do_scroll_restoration` is [`true`], [`HashHistory`] will take control of the history
208    /// state. It'll also set the browsers scroll restoration to `manual`.
209    pub fn new(do_scroll_restoration: bool) -> Self {
210        let myself = Self::new_inner(do_scroll_restoration);
211
212        let current_route = dioxus_history::History::current_route(&myself);
213        let current_route_str = current_route.to_string();
214        let pathname_str = &myself.pathname;
215        let current_url = format!("{pathname_str}#{current_route_str}");
216        let state = myself.create_state();
217        let _ = replace_state_with_url(&myself.history, &state, Some(&current_url));
218
219        myself
220    }
221
222    fn new_inner(do_scroll_restoration: bool) -> Self {
223        let window = window().expect("access to `window`");
224        let history = window.history().expect("`window` has access to `history`");
225        let pathname = window.location().pathname().unwrap();
226
227        if do_scroll_restoration {
228            history
229                .set_scroll_restoration(ScrollRestoration::Manual)
230                .expect("`history` can set scroll restoration");
231        }
232
233        Self {
234            do_scroll_restoration,
235            history,
236            pathname,
237            window,
238        }
239    }
240
241    fn scroll_pos(&self) -> ScrollPosition {
242        if self.do_scroll_restoration {
243            ScrollPosition::of_window(&self.window)
244        } else {
245            Default::default()
246        }
247    }
248
249    fn create_state(&self) -> [f64; 2] {
250        let scroll = self.scroll_pos();
251        [scroll.x, scroll.y]
252    }
253
254    fn full_path(&self, state: &String) -> String {
255        format!("{}#{state}", self.pathname)
256    }
257
258    fn handle_nav(&self) {
259        if self.do_scroll_restoration {
260            self.window.scroll_to_with_x_and_y(0.0, 0.0)
261        }
262    }
263}
264
265impl dioxus_history::History for HashHistory {
266    fn current_route(&self) -> String {
267        let location = self.window.location();
268
269        let hash = location.hash().unwrap();
270        if hash.is_empty() {
271            // If the path is empty, parse the root route instead
272            "/".to_owned()
273        } else {
274            hash.trim_start_matches("#").to_owned()
275        }
276    }
277
278    fn current_prefix(&self) -> Option<String> {
279        Some(format!("{}#", self.pathname))
280    }
281
282    fn go_back(&self) {
283        let _ = self.history.back();
284    }
285
286    fn go_forward(&self) {
287        let _ = self.history.forward();
288    }
289
290    fn push(&self, state: String) {
291        if state == self.current_route() {
292            // don't push the same state twice
293            return;
294        }
295
296        let w = window().expect("access to `window`");
297        let h = w.history().expect("`window` has access to `history`");
298
299        // update the scroll position before pushing the new state
300        update_scroll(&w, &h);
301
302        if push_state_and_url(&self.history, &self.create_state(), self.full_path(&state)).is_ok() {
303            self.handle_nav();
304        }
305    }
306
307    fn replace(&self, state: String) {
308        if replace_state_with_url(
309            &self.history,
310            &self.create_state(),
311            Some(&self.full_path(&state)),
312        )
313        .is_ok()
314        {
315            self.handle_nav();
316        }
317    }
318
319    fn external(&self, url: String) -> bool {
320        self.window.location().set_href(&url).is_ok()
321    }
322
323    fn updater(&self, callback: std::sync::Arc<dyn Fn() + Send + Sync>) {
324        let w = self.window.clone();
325        let h = self.history.clone();
326        let d = self.do_scroll_restoration;
327
328        let function = Closure::wrap(Box::new(move |_| {
329            (*callback)();
330            if d && let Some([x, y]) = get_current(&h) {
331                ScrollPosition { x, y }.scroll_to(w.clone())
332            }
333        }) as Box<dyn FnMut(Event)>);
334        self.window
335            .add_event_listener_with_callback(
336                "popstate",
337                &function.into_js_value().unchecked_into(),
338            )
339            .unwrap();
340    }
341}
342
343#[derive(Clone, Copy, Debug, Default)]
344pub(crate) struct ScrollPosition {
345    pub x: f64,
346    pub y: f64,
347}
348
349impl ScrollPosition {
350    pub(crate) fn of_window(window: &Window) -> Self {
351        Self {
352            x: window.scroll_x().unwrap_or_default(),
353            y: window.scroll_y().unwrap_or_default(),
354        }
355    }
356
357    pub(crate) fn scroll_to(&self, window: Window) {
358        let Self { x, y } = *self;
359        let f = Closure::wrap(
360            Box::new(move || window.scroll_to_with_x_and_y(x, y)) as Box<dyn FnMut()>
361        );
362        web_sys::window()
363            .expect("should be run in a context with a `Window` object (dioxus cannot be run from a web worker)")
364            .request_animation_frame(&f.into_js_value().unchecked_into())
365            .expect("should register `requestAnimationFrame` OK");
366    }
367}
368
369pub(crate) fn replace_state_with_url(
370    history: &History,
371    value: &[f64; 2],
372    url: Option<&str>,
373) -> Result<(), JsValue> {
374    let position = js_sys::Array::new();
375    position.push(&JsValue::from(value[0]));
376    position.push(&JsValue::from(value[1]));
377    history.replace_state_with_url(&position, "", url)
378}
379
380pub(crate) fn push_state_and_url(
381    history: &History,
382    value: &[f64; 2],
383    url: String,
384) -> Result<(), JsValue> {
385    let position = js_sys::Array::new();
386    position.push(&JsValue::from(value[0]));
387    position.push(&JsValue::from(value[1]));
388    history.push_state_with_url(&position, "", Some(&url))
389}
390
391pub(crate) fn get_current(history: &History) -> Option<[f64; 2]> {
392    use wasm_bindgen::JsCast;
393    history.state().ok().and_then(|state| {
394        let state = state.dyn_into::<js_sys::Array>().ok()?;
395        let x = state.get(0).as_f64()?;
396        let y = state.get(1).as_f64()?;
397        Some([x, y])
398    })
399}
400
401fn update_scroll(window: &Window, history: &History) {
402    let scroll = ScrollPosition::of_window(window);
403    let _ = replace_state_with_url(history, &[scroll.x, scroll.y], None);
404}