diff --git a/crates/history/Cargo.toml b/crates/history/Cargo.toml index edc5e397..f5dae048 100644 --- a/crates/history/Cargo.toml +++ b/crates/history/Cargo.toml @@ -23,7 +23,7 @@ wasm-bindgen = "0.2.114" [dependencies.web-sys] version = "0.3" -features = ["History", "Window", "Location", "Url"] +features = ["console", "History", "Window", "Location", "Url"] [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.2.17", features = ["js"] } diff --git a/crates/history/src/hash.rs b/crates/history/src/hash.rs index 94e95fad..e1e6ceb8 100644 --- a/crates/history/src/hash.rs +++ b/crates/history/src/hash.rs @@ -16,7 +16,16 @@ use crate::{error::HistoryResult, query::ToQuery}; /// /// # Panics /// -/// HashHistory does not support relative paths and will panic if routes are not starting with `/`. +/// The `push` and `replace` family of methods do not support relative paths +/// and will panic if the provided route does not start with `/`. +/// +/// # Hash Normalization +/// +/// If the URL hash is manually edited by the user to a value that does not +/// start with `#/`, calling `location()` will **not** panic. Instead, it will: +/// 1. Log a warning to the browser console. +/// 2. Normalize the hash by prepending `/` if missing. +/// 3. Silently correct the URL in the address bar via `replaceState`. #[derive(Clone, PartialEq)] pub struct HashHistory { inner: BrowserHistory, @@ -195,19 +204,29 @@ impl History for HashHistory { fn location(&self) -> Location { let inner_loc = self.inner.location(); - // We strip # from hash. - let hash_url = inner_loc.hash().chars().skip(1).collect::(); - assert_absolute_path(&hash_url); + // Strip the leading '#' from the hash. + let raw_hash = inner_loc.hash().strip_prefix('#').unwrap_or("").to_string(); + + // Normalize: ensure it starts with '/'. Log a warning if it didn't. + let needs_correction = raw_hash.is_empty() || !raw_hash.starts_with('/'); + let normalized = Self::normalize_hash(&raw_hash); let hash_url = Url::new_with_base( - &hash_url, + &normalized, &window() .location() .href() .expect_throw("failed to get location href."), ) - .expect_throw("failed to get make url"); + .expect_throw("failed to make url"); + + // Auto-correct the URL in the address bar so it stays canonical. + if needs_correction { + let url = Self::get_url(); + url.set_hash(&format!("#{normalized}")); + self.inner.replace(url.href()); + } Location { path: hash_url.pathname().into(), @@ -225,6 +244,33 @@ impl HashHistory { Self::default() } + /// Takes the raw content after '#' and ensures it starts with '/'. + /// If it doesn't, prepends '/' and logs a warning. + /// If it is empty, returns "/". + fn normalize_hash(raw: &str) -> String { + if raw.is_empty() { + web_sys::console::warn_1( + &"[gloo_history] HashHistory: URL hash is empty, defaulting to '/'. \ + The hash was auto-corrected to '#/'." + .into(), + ); + "/".to_string() + } else if !raw.starts_with('/') { + web_sys::console::warn_1( + &format!( + "[gloo_history] HashHistory: URL hash '#{}' does not start with '/'. \ + The hash was normalized to '#/{}'. \ + Ensure hash-based routes always begin with '#/'.", + raw, raw + ) + .into(), + ); + format!("/{raw}") + } else { + raw.to_string() + } + } + fn get_url() -> Url { let href = window() .location() diff --git a/crates/history/tests/hash_history.rs b/crates/history/tests/hash_history.rs index abe8047a..290a7a6a 100644 --- a/crates/history/tests/hash_history.rs +++ b/crates/history/tests/hash_history.rs @@ -8,6 +8,9 @@ wasm_bindgen_test_configure!(run_in_browser); mod utils; use utils::delayed_assert_eq; +// All assertions live in a single test because HashHistory is a thread-local +// singleton backed by a shared browser URL, so separate tests would leak +// state into each other depending on execution order. #[test] async fn history_works() { let history = HashHistory::new(); @@ -50,4 +53,24 @@ async fn history_works() { } delayed_assert_eq(|| window().location().pathname().unwrap(), || "/").await; delayed_assert_eq(|| window().location().hash().unwrap(), || "#/path-b").await; + + // Malformed hash: simulate user editing the URL bar to a hash without '/' + window().location().set_hash("no-leading-slash").unwrap(); + + let location = history.location(); + assert_eq!(location.path(), "/no-leading-slash"); + + delayed_assert_eq( + || window().location().hash().unwrap(), + || "#/no-leading-slash", + ) + .await; + + // Empty hash: simulate user clearing the hash entirely + window().location().set_hash("").unwrap(); + + let location = history.location(); + assert_eq!(location.path(), "/"); + + delayed_assert_eq(|| window().location().hash().unwrap(), || "#/").await; }