diff --git a/.gitignore b/.gitignore index 07974b162..a1737042a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .dioxus /target +/packages/playwright-tests/cli-optimization/monaco-editor-0.52.2 /packages/playwright-tests/web/dist /packages/playwright-tests/fullstack/dist /packages/playwright-tests/test-results diff --git a/Cargo.lock b/Cargo.lock index d0a3863d2..fcd65738f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4372,6 +4372,13 @@ dependencies = [ "tokio", ] +[[package]] +name = "dioxus-playwright-web-hash-routing-test" +version = "0.0.1" +dependencies = [ + "dioxus", +] + [[package]] name = "dioxus-playwright-web-routing-test" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index a3a04b7fc..9ba51bf5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,7 @@ members = [ "packages/playwright-tests/liveview", "packages/playwright-tests/web", "packages/playwright-tests/web-routing", + "packages/playwright-tests/web-hash-routing", "packages/playwright-tests/barebones-template", "packages/playwright-tests/fullstack", "packages/playwright-tests/fullstack-mounted", diff --git a/packages/playwright-tests/playwright.config.js b/packages/playwright-tests/playwright.config.js index 188f1fab6..9e445f82f 100644 --- a/packages/playwright-tests/playwright.config.js +++ b/packages/playwright-tests/playwright.config.js @@ -102,6 +102,15 @@ module.exports = defineConfig({ reuseExistingServer: !process.env.CI, stdout: "pipe", }, + { + cwd: path.join(process.cwd(), "web-hash-routing"), + command: + 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --platform web --addr "127.0.0.1" --port 2021', + port: 2021, + timeout: 50 * 60 * 1000, + reuseExistingServer: !process.env.CI, + stdout: "pipe", + }, { cwd: path.join(process.cwd(), "fullstack"), command: diff --git a/packages/playwright-tests/web-hash-routing.spec.js b/packages/playwright-tests/web-hash-routing.spec.js new file mode 100644 index 000000000..7e3206daa --- /dev/null +++ b/packages/playwright-tests/web-hash-routing.spec.js @@ -0,0 +1,31 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); + +test("redirect", async ({ page }) => { + // Going to the root url should redirect to /other. + await page.goto("http://localhost:2021"); + + // Expect the page to the text Other + const main = page.locator("#other"); + await expect(main).toContainText("Other"); + + // Expect the url to be /#/other + await expect(page).toHaveURL("http://localhost:2021/#/other"); +}); + +test("links", async ({ page }) => { + await page.goto("http://localhost:2021/#/other"); + + // Expect clicking the link to /other/123 to navigate to /other/123 + const link = page.locator("a[href='/#/other/123']"); + await link.click(); + await expect(page).toHaveURL("http://localhost:2021/#/other/123"); +}); + +test("fallback", async ({ page }) => { + await page.goto("http://localhost:2021/#/my/404/route"); + + // Expect the page to contain the text Fallback + const main = page.locator("#not-found"); + await expect(main).toContainText("NotFound"); +}); diff --git a/packages/playwright-tests/web-hash-routing/Cargo.toml b/packages/playwright-tests/web-hash-routing/Cargo.toml new file mode 100644 index 000000000..a40e6fa68 --- /dev/null +++ b/packages/playwright-tests/web-hash-routing/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "dioxus-playwright-web-hash-routing-test" +version = "0.0.1" +edition = "2021" +description = "Playwright test for Dioxus Web Routing" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["web", "router"]} diff --git a/packages/playwright-tests/web-hash-routing/src/main.rs b/packages/playwright-tests/web-hash-routing/src/main.rs new file mode 100644 index 000000000..13b6ef38e --- /dev/null +++ b/packages/playwright-tests/web-hash-routing/src/main.rs @@ -0,0 +1,61 @@ +use std::rc::Rc; + +use dioxus::{prelude::*, web::HashHistory}; + +fn main() { + dioxus::LaunchBuilder::new() + .with_cfg(dioxus::web::Config::new().history(Rc::new(HashHistory::new(false)))) + .launch(|| { + rsx! { + Router:: {} + } + }) +} + +#[derive(Routable, Clone, PartialEq)] +#[rustfmt::skip] +enum Route { + #[redirect("/",|| Route::Other)] + #[route("/other")] + Other, + #[route("/other/:id")] + OtherId { id: String }, + #[route("/:..segments")] + NotFound { segments: Vec }, +} + +#[component] +fn Other() -> Element { + rsx! { + div { + id: "other", + "Other" + } + + Link { + id: "other-id-link", + to: Route::OtherId { id: "123".to_string() }, + "go to OtherId" + } + } +} + +#[component] +fn OtherId(id: String) -> Element { + rsx! { + div { + id: "other-id", + "OtherId {id}" + } + } +} + +#[component] +fn NotFound(segments: Vec) -> Element { + rsx! { + div { + id: "not-found", + "NotFound {segments:?}" + } + } +} diff --git a/packages/web/src/cfg.rs b/packages/web/src/cfg.rs index c46ea36e1..8fd536f4f 100644 --- a/packages/web/src/cfg.rs +++ b/packages/web/src/cfg.rs @@ -1,3 +1,5 @@ +use std::rc::Rc; + use dioxus_core::LaunchConfig; use wasm_bindgen::JsCast as _; @@ -13,6 +15,8 @@ use wasm_bindgen::JsCast as _; pub struct Config { pub(crate) hydrate: bool, pub(crate) root: ConfigRoot, + #[cfg(feature = "document")] + pub(crate) history: Option>, } impl LaunchConfig for Config {} @@ -67,6 +71,22 @@ impl Config { self.root = ConfigRoot::RootNode(node); self } + + /// Set the history provider for the application. + /// + /// `dioxus-web` provides two history providers: + /// - `dioxus_history::WebHistory`: A history provider that uses the browser history API. + /// - `dioxus_history::HashHistory`: A history provider that uses the `#` url fragment. + /// + /// By default, `dioxus-web` uses the `WebHistory` provider, but this method can be used to configure + /// a different history provider. + pub fn history(mut self, history: Rc) -> Self { + #[cfg(feature = "document")] + { + self.history = Some(history); + } + self + } } impl Default for Config { @@ -74,6 +94,8 @@ impl Default for Config { Self { hydrate: false, root: ConfigRoot::RootName("main".to_string()), + #[cfg(feature = "document")] + history: None, } } } diff --git a/packages/web/src/history/mod.rs b/packages/web/src/history.rs similarity index 50% rename from packages/web/src/history/mod.rs rename to packages/web/src/history.rs index 260db7223..a5cb4f709 100644 --- a/packages/web/src/history/mod.rs +++ b/packages/web/src/history.rs @@ -1,24 +1,5 @@ -use scroll::ScrollPosition; -use wasm_bindgen::JsCast; -use wasm_bindgen::{prelude::Closure, JsValue}; -use web_sys::{window, Window}; -use web_sys::{Event, History, ScrollRestoration}; - -mod scroll; - -fn base_path() -> Option { - let base_path = dioxus_cli_config::web_base_path(); - tracing::trace!("Using base_path from the CLI: {:?}", base_path); - base_path -} - -#[allow(clippy::extra_unused_type_parameters)] -fn update_scroll(window: &Window, history: &History) { - let scroll = ScrollPosition::of_window(window); - if let Err(err) = replace_state_with_url(history, &[scroll.x, scroll.y], None) { - web_sys::console::error_1(&err); - } -} +use wasm_bindgen::{prelude::Closure, JsCast, JsValue}; +use web_sys::{window, Event, History, ScrollRestoration, Window}; /// 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). /// @@ -75,7 +56,7 @@ impl WebHistory { let prefix = prefix // If there isn't a base path, try to grab one from the CLI - .or_else(base_path) + .or_else(dioxus_cli_config::web_base_path) // Normalize the prefix to start and end with no slashes .as_ref() .map(|prefix| prefix.trim_matches('/')) @@ -102,9 +83,13 @@ impl WebHistory { let scroll = self.scroll_pos(); [scroll.x, scroll.y] } -} -impl WebHistory { + fn handle_nav(&self) { + if self.do_scroll_restoration { + self.window.scroll_to_with_x_and_y(0.0, 0.0) + } + } + fn route_from_location(&self) -> String { let location = self.window.location(); let path = location.pathname().unwrap_or_else(|_| "/".into()) @@ -127,34 +112,6 @@ impl WebHistory { Some(prefix) => format!("{prefix}{state}"), } } - - fn handle_nav(&self, result: Result<(), JsValue>) { - match result { - Ok(_) => { - if self.do_scroll_restoration { - self.window.scroll_to_with_x_and_y(0.0, 0.0) - } - } - Err(e) => { - web_sys::console::error_2(&JsValue::from_str("failed to change state: "), &e); - } - } - } - - fn navigate_external(&self, url: String) -> bool { - match self.window.location().set_href(&url) { - Ok(_) => true, - Err(e) => { - web_sys::console::error_4( - &JsValue::from_str("failed to navigate to external url ("), - &JsValue::from_str(&url), - &JsValue::from_str("): "), - &e, - ); - false - } - } - } } impl dioxus_history::History for WebHistory { @@ -167,15 +124,11 @@ impl dioxus_history::History for WebHistory { } fn go_back(&self) { - if let Err(e) = self.history.back() { - web_sys::console::error_2(&JsValue::from_str("failed to go back: "), &e); - } + let _ = self.history.back(); } fn go_forward(&self) { - if let Err(e) = self.history.forward() { - web_sys::console::error_2(&JsValue::from_str("failed to go forward: "), &e); - } + let _ = self.history.forward(); } fn push(&self, state: String) { @@ -190,21 +143,25 @@ impl dioxus_history::History for WebHistory { // update the scroll position before pushing the new state update_scroll(&w, &h); - let path = self.full_path(&state); - - let state: [f64; 2] = self.create_state(); - self.handle_nav(push_state_and_url(&self.history, &state, path)); + if push_state_and_url(&self.history, &self.create_state(), self.full_path(&state)).is_ok() { + self.handle_nav(); + } } fn replace(&self, state: String) { - let path = self.full_path(&state); - - let state = self.create_state(); - self.handle_nav(replace_state_with_url(&self.history, &state, Some(&path))); + if replace_state_with_url( + &self.history, + &self.create_state(), + Some(&self.full_path(&state)), + ) + .is_ok() + { + self.handle_nav(); + } } fn external(&self, url: String) -> bool { - self.navigate_external(url) + self.window.location().set_href(&url).is_ok() } fn updater(&self, callback: std::sync::Arc) { @@ -229,6 +186,186 @@ impl dioxus_history::History for WebHistory { } } +/// 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) +/// but uses the url fragment for the route. This allows serving as a single html file or on a single url path. +pub struct HashHistory { + do_scroll_restoration: bool, + history: History, + pathname: String, + window: Window, +} + +impl Default for HashHistory { + fn default() -> Self { + Self::new(true) + } +} + +impl HashHistory { + /// Create a new [`HashHistory`]. + /// + /// If `do_scroll_restoration` is [`true`], [`HashHistory`] 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 myself = Self::new_inner(do_scroll_restoration); + + let current_route = dioxus_history::History::current_route(&myself); + let current_route_str = current_route.to_string(); + let pathname_str = &myself.pathname; + let current_url = format!("{pathname_str}#{current_route_str}"); + let state = myself.create_state(); + let _ = replace_state_with_url(&myself.history, &state, Some(¤t_url)); + + myself + } + + fn new_inner(do_scroll_restoration: bool) -> Self { + let window = window().expect("access to `window`"); + let history = window.history().expect("`window` has access to `history`"); + let pathname = window.location().pathname().unwrap(); + + if do_scroll_restoration { + history + .set_scroll_restoration(ScrollRestoration::Manual) + .expect("`history` can set scroll restoration"); + } + + Self { + do_scroll_restoration, + history, + pathname, + window, + } + } + + fn scroll_pos(&self) -> ScrollPosition { + self.do_scroll_restoration + .then(|| ScrollPosition::of_window(&self.window)) + .unwrap_or_default() + } + + fn create_state(&self) -> [f64; 2] { + let scroll = self.scroll_pos(); + [scroll.x, scroll.y] + } + + fn full_path(&self, state: &String) -> String { + format!("{}#{state}", self.pathname) + } + + fn handle_nav(&self) { + if self.do_scroll_restoration { + self.window.scroll_to_with_x_and_y(0.0, 0.0) + } + } +} + +impl dioxus_history::History for HashHistory { + fn current_route(&self) -> String { + let location = self.window.location(); + + let hash = location.hash().unwrap(); + if hash.is_empty() { + // If the path is empty, parse the root route instead + "/".to_owned() + } else { + hash.trim_start_matches("#").to_owned() + } + } + + fn current_prefix(&self) -> Option { + Some(format!("{}#", self.pathname)) + } + + fn go_back(&self) { + let _ = self.history.back(); + } + + fn go_forward(&self) { + let _ = self.history.forward(); + } + + fn push(&self, state: String) { + if state == self.current_route() { + // don't push the same state twice + return; + } + + let w = window().expect("access to `window`"); + let h = w.history().expect("`window` has access to `history`"); + + // update the scroll position before pushing the new state + update_scroll(&w, &h); + + if push_state_and_url(&self.history, &self.create_state(), self.full_path(&state)).is_ok() { + self.handle_nav(); + } + } + + fn replace(&self, state: String) { + if replace_state_with_url( + &self.history, + &self.create_state(), + Some(&self.full_path(&state)), + ) + .is_ok() + { + self.handle_nav(); + } + } + + fn external(&self, url: String) -> bool { + self.window.location().set_href(&url).is_ok() + } + + fn updater(&self, callback: std::sync::Arc) { + let w = self.window.clone(); + let h = self.history.clone(); + let d = self.do_scroll_restoration; + + let function = Closure::wrap(Box::new(move |_| { + (*callback)(); + if d { + if let Some([x, y]) = get_current(&h) { + ScrollPosition { x, y }.scroll_to(w.clone()) + } + } + }) as Box); + self.window + .add_event_listener_with_callback( + "popstate", + &function.into_js_value().unchecked_into(), + ) + .unwrap(); + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct ScrollPosition { + pub x: f64, + pub y: f64, +} + +impl ScrollPosition { + pub(crate) fn of_window(window: &Window) -> Self { + Self { + x: window.scroll_x().unwrap_or_default(), + y: window.scroll_y().unwrap_or_default(), + } + } + + pub(crate) fn scroll_to(&self, window: Window) { + let Self { x, y } = *self; + let f = Closure::wrap( + Box::new(move || window.scroll_to_with_x_and_y(x, y)) as Box + ); + web_sys::window() + .expect("should be run in a context with a `Window` object (dioxus cannot be run from a web worker)") + .request_animation_frame(&f.into_js_value().unchecked_into()) + .expect("should register `requestAnimationFrame` OK"); + } +} + pub(crate) fn replace_state_with_url( history: &History, value: &[f64; 2], @@ -237,7 +374,6 @@ pub(crate) fn replace_state_with_url( let position = js_sys::Array::new(); position.push(&JsValue::from(value[0])); position.push(&JsValue::from(value[1])); - history.replace_state_with_url(&position, "", url) } @@ -249,21 +385,20 @@ pub(crate) fn push_state_and_url( let position = js_sys::Array::new(); position.push(&JsValue::from(value[0])); position.push(&JsValue::from(value[1])); - history.push_state_with_url(&position, "", Some(&url)) } pub(crate) fn get_current(history: &History) -> Option<[f64; 2]> { use wasm_bindgen::JsCast; - - let state = history.state(); - if let Err(err) = &state { - web_sys::console::error_1(err); - } - state.ok().and_then(|state| { + history.state().ok().and_then(|state| { let state = state.dyn_into::().ok()?; let x = state.get(0).as_f64()?; let y = state.get(1).as_f64()?; Some([x, y]) }) } + +fn update_scroll(window: &Window, history: &History) { + let scroll = ScrollPosition::of_window(window); + let _ = replace_state_with_url(history, &[scroll.x, scroll.y], None); +} diff --git a/packages/web/src/history/scroll.rs b/packages/web/src/history/scroll.rs deleted file mode 100644 index 806696665..000000000 --- a/packages/web/src/history/scroll.rs +++ /dev/null @@ -1,28 +0,0 @@ -use wasm_bindgen::{prelude::Closure, JsCast}; -use web_sys::Window; - -#[derive(Clone, Copy, Debug, Default)] -pub(crate) struct ScrollPosition { - pub x: f64, - pub y: f64, -} - -impl ScrollPosition { - pub(crate) fn of_window(window: &Window) -> Self { - Self { - x: window.scroll_x().unwrap_or_default(), - y: window.scroll_y().unwrap_or_default(), - } - } - - pub(crate) fn scroll_to(&self, window: Window) { - let Self { x, y } = *self; - let f = Closure::wrap( - Box::new(move || window.scroll_to_with_x_and_y(x, y)) as Box - ); - web_sys::window() - .expect("should be run in a context with a `Window` object (dioxus cannot be run from a web worker)") - .request_animation_frame(&f.into_js_value().unchecked_into()) - .expect("should register `requestAnimationFrame` OK"); - } -} diff --git a/packages/web/src/lib.rs b/packages/web/src/lib.rs index 34c211a36..d851f35e0 100644 --- a/packages/web/src/lib.rs +++ b/packages/web/src/lib.rs @@ -31,7 +31,7 @@ pub use document::WebDocument; #[cfg(feature = "file_engine")] pub use file_engine::*; #[cfg(feature = "document")] -pub use history::WebHistory; +pub use history::{HashHistory, WebHistory}; #[cfg(all(feature = "devtools", debug_assertions))] mod devtools; @@ -51,6 +51,11 @@ pub use hydration::*; /// wasm_bindgen_futures::spawn_local(app_fut); /// ``` pub async fn run(mut virtual_dom: VirtualDom, web_config: Config) -> ! { + #[cfg(feature = "document")] + if let Some(history) = web_config.history.clone() { + virtual_dom.in_runtime(|| dioxus_core::ScopeId::ROOT.provide_context(history)); + } + #[cfg(feature = "document")] virtual_dom.in_runtime(document::init_document);